Compare commits

...
Author SHA1 Message Date
Anthony Stirling d9e10bf031 validate fixes 2025-10-15 10:49:42 +01:00
Anthony Stirling 14a2be5524 cleanups 2025-10-14 23:27:47 +01:00
Anthony Stirling 1c4838f6c5 init 2025-10-14 23:11:38 +01:00
EthanHealy01 6f42737ca9 Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into feature/V2/ValidatePDFSignature 2025-10-14 18:48:13 +01:00
EthanHealy01 543a8ce422 styling 2025-10-14 18:38:17 +01:00
EthanHealy01 538782eec6 V2 Validate PDF Signature tool 2025-10-14 17:57:58 +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
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
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
b54beaa66b 🌐 [V2] Sync Translations + Update README Progress Table (#4618)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation for the **V2 branch**. Below are the
details of the changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.json`) to reflect changes in the
reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-10 16:42:04 +01:00
e542f4bc61 legacy UI (#4612)
# 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: Claude <noreply@anthropic.com>
Co-authored-by: EthanHealy01 <ethan.healy.21@gmail.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2025-10-10 15:36:35 +01:00
EthanHealy01andGitHub a339f71116 Overlay PDF tool (#4620)
# Description of Changes

- Added the OverlayPDF tool

---

## 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-10 14:35:09 +01:00
127 changed files with 9243 additions and 955 deletions
+104
View File
@@ -0,0 +1,104 @@
# Frontend TODO: Revocation Status Migration
## Background
The backend has removed the deprecated `notRevoked` boolean field in favor of `revocationStatus` string field.
**revocationStatus values**:
- `"not-checked"` - revocation checking was disabled
- `"good"` - certificate was checked and is not revoked
- `"revoked"` - certificate is revoked
- `"soft-fail"` - revocation status couldn't be determined (network error, etc.)
- `"unknown"` - other failure scenarios
## Files That Need Changes
### 1. `/frontend/src/hooks/tools/validateSignature/utils/signatureUtils.ts`
Add mappings for new backend fields in `normalizeBackendResult()`:
```typescript
export const normalizeBackendResult = (
item: SignatureValidationBackendResult,
stirlingFile: StirlingFile,
index: number
): SignatureValidationSignature => ({
id: `${stirlingFile.fileId}-${index}`,
valid: Boolean(item.valid),
chainValid: Boolean(item.chainValid),
trustValid: Boolean(item.trustValid),
chainValidationError: item.chainValidationError ?? null, // ADD THIS
certPathLength: item.certPathLength ?? null, // ADD THIS
notExpired: Boolean(item.notExpired),
revocationChecked: item.revocationChecked ?? null, // ADD THIS
revocationStatus: item.revocationStatus ?? null, // ADD THIS
validationTimeSource: item.validationTimeSource ?? null, // ADD THIS
signerName: coerceString(item.signerName),
// ... rest of fields
})
```
### 2. `/frontend/src/hooks/tools/validateSignature/utils/signatureStatus.ts`
**Current code** (lines 42-43):
```typescript
// Use new revocationStatus field if available, fallback to notRevoked for backward compatibility
const revStatus = signature.revocationStatus || (signature.notRevoked ? 'good' : 'unknown');
```
**Change to**:
```typescript
const revStatus = signature.revocationStatus || 'unknown';
```
### 3. `/frontend/src/hooks/tools/validateSignature/utils/signatureCsv.ts`
**Current code** (lines 12, 42):
```typescript
'notRevoked', // line 12 in CSV header
booleanToString(signature.notRevoked), // line 42 in data row
```
**Recommended change** - replace with detailed status:
```typescript
// Header:
'revocationStatus',
// Data:
signature.revocationStatus || 'unknown',
```
### 4. `/frontend/src/hooks/tools/validateSignature/utils/reportStatus.ts`
**Current code** (line 24):
```typescript
(sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.notRevoked
```
**Change to**:
```typescript
(sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.revocationStatus === 'good'
```
### 5. `/frontend/src/components/tools/validateSignature/ValidateSignatureResults.tsx`
**Current code** (line 33):
```typescript
signature.notRevoked;
```
**Change to**:
```typescript
signature.revocationStatus === 'good'
```
## Migration Pattern
**For boolean contexts** (if statements, filters):
```typescript
// Old: signature.notRevoked
// New: signature.revocationStatus === 'good'
```
**For display/logging**:
```typescript
signature.revocationStatus // "good" | "revoked" | "soft-fail" | "not-checked" | "unknown"
```
+30 -30
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![97%](https://geps.dev/progress/97) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![36%](https://geps.dev/progress/36) |
| Basque (Euskara) (eu_ES) | ![21%](https://geps.dev/progress/21) |
| Arabic (العربية) (ar_AR) | ![95%](https://geps.dev/progress/95) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![35%](https://geps.dev/progress/35) |
| Basque (Euskara) (eu_ES) | ![20%](https://geps.dev/progress/20) |
| Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) |
| Catalan (Català) (ca_CA) | ![38%](https://geps.dev/progress/38) |
| Croatian (Hrvatski) (hr_HR) | ![35%](https://geps.dev/progress/35) |
| Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) |
| Catalan (Català) (ca_CA) | ![37%](https://geps.dev/progress/37) |
| Croatian (Hrvatski) (hr_HR) | ![34%](https://geps.dev/progress/34) |
| Czech (Česky) (cs_CZ) | ![38%](https://geps.dev/progress/38) |
| Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) |
| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![95%](https://geps.dev/progress/95) |
| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) |
| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) |
| Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) |
| Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![35%](https://geps.dev/progress/35) |
| Irish (Gaeilge) (ga_IE) | ![39%](https://geps.dev/progress/39) |
| Italian (Italiano) (it_IT) | ![97%](https://geps.dev/progress/97) |
| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) |
| Korean (한국어) (ko_KR) | ![39%](https://geps.dev/progress/39) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![95%](https://geps.dev/progress/95) |
| Greek (Ελληνικά) (el_GR) | ![38%](https://geps.dev/progress/38) |
| Hindi (हिंदी) (hi_IN) | ![38%](https://geps.dev/progress/38) |
| Hungarian (Magyar) (hu_HU) | ![42%](https://geps.dev/progress/42) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![34%](https://geps.dev/progress/34) |
| Irish (Gaeilge) (ga_IE) | ![38%](https://geps.dev/progress/38) |
| Italian (Italiano) (it_IT) | ![95%](https://geps.dev/progress/95) |
| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![38%](https://geps.dev/progress/38) |
| Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) |
| Portuguese (Português) (pt_PT) | ![39%](https://geps.dev/progress/39) |
| Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) |
| Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) |
| Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) |
| Polish (Polski) (pl_PL) | ![40%](https://geps.dev/progress/40) |
| Portuguese (Português) (pt_PT) | ![38%](https://geps.dev/progress/38) |
| Portuguese Brazilian (Português) (pt_BR) | ![95%](https://geps.dev/progress/95) |
| Romanian (Română) (ro_RO) | ![32%](https://geps.dev/progress/32) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) |
| Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) |
| Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) |
| Simplified Chinese (简体中文) (zh_CN) | ![96%](https://geps.dev/progress/96) |
| Slovakian (Slovensky) (sk_SK) | ![28%](https://geps.dev/progress/28) |
| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) |
| Spanish (Español) (es_ES) | ![97%](https://geps.dev/progress/97) |
| Swedish (Svenska) (sv_SE) | ![38%](https://geps.dev/progress/38) |
| Thai (ไทย) (th_TH) | ![35%](https://geps.dev/progress/35) |
| Spanish (Español) (es_ES) | ![95%](https://geps.dev/progress/95) |
| Swedish (Svenska) (sv_SE) | ![37%](https://geps.dev/progress/37) |
| Thai (ไทย) (th_TH) | ![34%](https://geps.dev/progress/34) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) |
| Turkish (Türkçe) (tr_TR) | ![42%](https://geps.dev/progress/42) |
| Ukrainian (Українська) (uk_UA) | ![41%](https://geps.dev/progress/41) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![32%](https://geps.dev/progress/32) |
| Traditional Chinese (繁體中文) (zh_TW) | ![42%](https://geps.dev/progress/42) |
| Turkish (Türkçe) (tr_TR) | ![41%](https://geps.dev/progress/41) |
| Ukrainian (Українська) (uk_UA) | ![40%](https://geps.dev/progress/40) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![31%](https://geps.dev/progress/31) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -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();
@@ -308,6 +309,41 @@ public class ApplicationProperties {
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;
}
}
}
@Data
@@ -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;
}
}
@@ -1946,6 +1946,25 @@ viewer.zoomIn=Zoom in
# Tool Picker
toolPicker.searchPlaceholder=Search tools...
toolPicker.noToolsFound=No tools found
toolPanel.toggle.legacy=Switch to legacy mode
toolPanel.toggle.sidebar=Switch to sidebar mode
toolPanel.placeholder=Choose a tool to get started
toolPanel.legacy.heading=All tools (legacy view)
toolPanel.legacy.tagline=Browse and launch tools while keeping the classic full-width gallery.
toolPanel.legacy.descriptionsOn=Showing descriptions
toolPanel.legacy.descriptionsOff=Descriptions hidden
toolPanel.legacy.noResults=Try adjusting your search or toggle descriptions to find what you need.
toolPanel.legacy.matchedSynonym=Matches "{{text}}"
toolPanel.modePrompt.title=Choose how you browse tools
toolPanel.modePrompt.description=Preview both layouts and decide how you want to explore Stirling PDF tools.
toolPanel.modePrompt.sidebarTitle=Advanced sidebar
toolPanel.modePrompt.sidebarDescription=Keep tools alongside your workspace for quick switching.
toolPanel.modePrompt.recommended=Recommended
toolPanel.modePrompt.chooseSidebar=Use advanced sidebar
toolPanel.modePrompt.legacyTitle=Legacy fullscreen
toolPanel.modePrompt.legacyDescription=Browse every tool in a catalogue that covers the workspace until you pick one.
toolPanel.modePrompt.chooseLegacy=Use legacy fullscreen
toolPanel.modePrompt.dismiss=Maybe later
pageEditor.reset=Reset Changes
pageEditor.zoomIn=Zoom In
pageEditor.zoomOut=Zoom Out
@@ -1958,3 +1977,39 @@ viewer.nextPage=Next Page
viewer.pageNavigation=Page Navigation
viewer.currentPage=Current Page
viewer.totalPages=Total Pages
toolPanel.legacy.favorites=Favourites
toolPanel.legacy.recent=Recently used
toolPanel.legacy.favorite=Add to favourites
toolPanel.legacy.unfavorite=Remove from favourites
toolPanel.legacy.settings.title=Customise appearance
toolPanel.legacy.settings.iconBackground.label=Tool icon background
toolPanel.legacy.settings.iconBackground.description=When to show coloured backgrounds behind tool icons
toolPanel.legacy.settings.iconBackground.none=None
toolPanel.legacy.settings.iconBackground.hover=On hover
toolPanel.legacy.settings.iconBackground.always=Always
toolPanel.legacy.settings.iconColor.label=Tool icon colour
toolPanel.legacy.settings.iconColor.description=Colour scheme for tool icons
toolPanel.legacy.settings.iconColor.colored=Coloured
toolPanel.legacy.settings.iconColor.vibrant=Vibrant
toolPanel.legacy.settings.iconColor.monochrome=Monochrome
toolPanel.legacy.settings.sectionTitle.label=Section titles
toolPanel.legacy.settings.sectionTitle.description=Colour for category section titles
toolPanel.legacy.settings.sectionTitle.colored=Coloured
toolPanel.legacy.settings.sectionTitle.neutral=Neutral
toolPanel.legacy.settings.headerIcon.label=Section header icons
toolPanel.legacy.settings.headerIcon.description=Colour for Favourites/Recent icons
toolPanel.legacy.settings.headerIcon.colored=Coloured
toolPanel.legacy.settings.headerIcon.monochrome=Monochrome
toolPanel.legacy.settings.headerBadge.label=Section header badges
toolPanel.legacy.settings.headerBadge.description=Colour for count badges in section headers
toolPanel.legacy.settings.headerBadge.colored=Coloured
toolPanel.legacy.settings.headerBadge.neutral=Neutral
toolPanel.legacy.settings.border.label=Tool item borders
toolPanel.legacy.settings.border.description=Show borders around tool items
toolPanel.legacy.settings.border.visible=Visible
toolPanel.legacy.settings.border.hidden=Hidden
toolPanel.legacy.settings.hover.label=Hover effect intensity
toolPanel.legacy.settings.hover.description=How prominent the hover effect should be
toolPanel.legacy.settings.hover.subtle=Subtle
toolPanel.legacy.settings.hover.moderate=Moderate
toolPanel.legacy.settings.hover.prominent=Prominent
@@ -65,6 +65,22 @@ security:
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
@@ -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,82 @@ 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(validation.isEnableEUTL()).thenReturn(false);
when(trust.isServerAsAnchor()).thenReturn(false);
when(trust.isUseSystemTrust()).thenReturn(false);
when(trust.isUseMozillaBundle()).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
// Then it should not be outside validity period
assertFalse(result, "Valid certificate should not be outside validity period");
}
@Test
void testIsOutsideValidityPeriod_ExpiredCertificate() {
// When certificate is expired
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
// Then it should be outside validity period
assertTrue(result, "Expired certificate should be outside validity period");
}
@Test
@SuppressWarnings("deprecation")
void testDeprecatedIsRevoked_ValidCertificate() {
// Test deprecated method for backwards compatibility
boolean result = validationService.isRevoked(validCertificate);
assertFalse(result, "Valid certificate should not be considered revoked");
}
@Test
void testIsRevoked_ExpiredCertificate() {
// When certificate is expired
@SuppressWarnings("deprecation")
void testDeprecatedIsRevoked_ExpiredCertificate() {
// Test deprecated method for backwards compatibility
boolean result = validationService.isRevoked(expiredCertificate);
// Then it should be considered revoked
assertTrue(result, "Expired certificate should be considered revoked");
assertTrue(result, "Expired certificate should be considered revoked (legacy behavior)");
}
@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.
}
+137 -136
View File
@@ -10,24 +10,24 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.1",
"@embedpdf/engines": "^1.3.1",
"@embedpdf/plugin-annotation": "^1.3.1",
"@embedpdf/plugin-export": "^1.3.1",
"@embedpdf/plugin-history": "^1.3.1",
"@embedpdf/plugin-interaction-manager": "^1.3.1",
"@embedpdf/plugin-loader": "^1.3.1",
"@embedpdf/plugin-pan": "^1.3.1",
"@embedpdf/plugin-render": "^1.3.1",
"@embedpdf/plugin-rotate": "^1.3.1",
"@embedpdf/plugin-scroll": "^1.3.1",
"@embedpdf/plugin-search": "^1.3.1",
"@embedpdf/plugin-selection": "^1.3.1",
"@embedpdf/plugin-spread": "^1.3.1",
"@embedpdf/plugin-thumbnail": "^1.3.1",
"@embedpdf/plugin-tiling": "^1.3.1",
"@embedpdf/plugin-viewport": "^1.3.1",
"@embedpdf/plugin-zoom": "^1.3.1",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -497,13 +497,13 @@
}
},
"node_modules/@embedpdf/core": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"integrity": "sha512-2Az6trhiMMBIv+GFvV8H8UOS1gwQn7NK0KaJMcdsZbUHYLO0P95aVd6Pi/GRzEH4XyF51TDIoTOAUtf07TQ5dQ==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz",
"integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==",
"license": "MIT",
"dependencies": {
"@embedpdf/engines": "1.3.1",
"@embedpdf/models": "1.3.1"
"@embedpdf/engines": "1.3.14",
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -513,13 +513,13 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.1.tgz",
"integrity": "sha512-G3pI+18la7spviUMuA5s9/hV95jlfkA2+CNxqlHBO5ocw3641E3d36Lv+mx+6yU7k0B5vEOQPZDGRMg7KFziBQ==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.14.tgz",
"integrity": "sha512-+/FPW2gAzj2lQYvsMH/Oj9+MEXgkyEuyYDC+HFkltTuXvmiP2S/3BD0YslZDX9K4BzcmMxnWB+BiQpNJokbDVg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1",
"@embedpdf/pdfium": "1.3.1"
"@embedpdf/models": "1.3.14",
"@embedpdf/pdfium": "1.3.14"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -529,31 +529,31 @@
}
},
"node_modules/@embedpdf/models": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.1.tgz",
"integrity": "sha512-OzmO1rQAuOP/Y3aYXmW21dPNAx49olhr9ZO2hDdI0fbNBHTVGxnaKqOISxVmUz7TmhTwVBljERACnaA8Ib4b4Q==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.14.tgz",
"integrity": "sha512-BujY4bmr8b2DQdoZkOge03SzoRVoWxzfIQATLSPPtp4WiFh1U4BPp6cADlGuCwGkp6zBcH/aM4h8PwwA75d/eg==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.1.tgz",
"integrity": "sha512-qYGSS5ntz6DSY9Cxw/aigvHqGB+AKJLEcymNTZOL0GdlBzZpL++dOIYNEYHO2Tm/lOQVpE7I0e+Xh2TvD8O1zQ==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.14.tgz",
"integrity": "sha512-TQMZabXzHmzvvfPwopubFcYgQuYV7POvMgjICYu3Pgfn3sgr+UdIUh3aNXR/COcl3q8sXPMFQ2GDuyOHR9QQnA==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.1.tgz",
"integrity": "sha512-mmePRYYBB8v8NIZ95XVfFkpyQ2QiKIGdWyvrPeJXSbL3/K6d6ix+o/jHBVvBWyTsQzdIlzs+FW8+iT0M1zkEow==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.14.tgz",
"integrity": "sha512-JJYqEWwUKCdBZsXCDq/CW96p3pVLn8N+XZ4W3OyL7djI2fvYC9x6ys9m82vwlSathAVOxk1D7xXiY8AzJQVF0Q==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1",
"@embedpdf/utils": "1.3.1"
"@embedpdf/models": "1.3.14",
"@embedpdf/utils": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-history": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-selection": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-history": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-selection": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -561,15 +561,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.1.tgz",
"integrity": "sha512-reb03vNPFP5GuIAFExMcuYBVYu/deVO2v8EoCwRZ/lzzYMORIkJjpNWDQPo9VfyGBh1x4/o3CHvxisU1Y1tDLg==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.14.tgz",
"integrity": "sha512-fMGp2YxvI4uTRIViUKxfnJts2Jw/vktEM45XUNGNSjT/kAW6znVNgdceYjpK++xU8CGs2grAQ1i5UvMd3aRNDA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -577,15 +577,15 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.1.tgz",
"integrity": "sha512-HrPkWQmAk08mbHiOcIN4htVq5KJMqI9zSjAqaYQEhV/TugeHfWVpK+xMst/PzuFb14HWgk5gWXjtV5E4SDlw9w==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz",
"integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -593,15 +593,15 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.1.tgz",
"integrity": "sha512-8h3y5a9tQ1fZlc4mP1/+XKyuHWwcQEm9AujKxy+6f6omtCBzpnKrH95bURgYOzQEBGY7d5C3HvG6JOlh0o1x3A==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz",
"integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -609,15 +609,15 @@
}
},
"node_modules/@embedpdf/plugin-loader": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.1.tgz",
"integrity": "sha512-NjNmA7TOs3E/zwb9I+YohzyGkxq8y5NUGu0MKgh2g41lZoFvyqTAjFPar+RjEiLX8iiJiwNZswyJsNrytmS3Xg==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz",
"integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -625,17 +625,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.1.tgz",
"integrity": "sha512-lF1gkz/a77G3+Rr8MOefkGnPJ1i5xWnClXm2ZzYAl7PbOScp59/PaP7qeU7eMPC4FHQM81ZhCgVYGXogbaB8ww==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.14.tgz",
"integrity": "sha512-7EG+I5nn8yDCV8pT4x/g5mv7zJli2t3wPrh6Kt8uIpUorPHNb6J0Z67gl0uc/8rEasNzuKOuT0er46Y6/UYLzQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -643,15 +643,15 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.1.tgz",
"integrity": "sha512-c9oH097e1CVUpYF9RgZRfV/7XCJ0pf+svdT1wyM2MbWby06ti20oCwT9wf7BLY0hPQ7+eO3wunr1I1/y3MnVrw==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz",
"integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -659,15 +659,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.1.tgz",
"integrity": "sha512-mRAlIW7IZAnCyDuYqN13yDc6yoNIYLUB4uYTUAR7vTIt021C8H5jDHk9TmLwcH0tQ8/R3yHuDm/XPAe0zfs81g==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.14.tgz",
"integrity": "sha512-OroEm11x/fPPXI9C0X+nm9LOjwaI0MvsToZRH+HpV60/FbQeOJvt6D8wThCDVLK95Na6A+JeYIMEu+Hiix7H+A==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -675,16 +675,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.1.tgz",
"integrity": "sha512-mDvK3DyBZC8/8pOEdJsWtSjCmV2ZuZJJ6xfspJpsaDVywo1Vq6M55BtKThkhqED6mqbFWTN9rP9cbWG8KDBWVA==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz",
"integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -692,16 +692,16 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.1.tgz",
"integrity": "sha512-SLwYPQg1NJWytq2sd4MnWFmRVGgzwbohBedB2kH0ALsvdnoRYqgjR5HqAsKgoRJO/pphQhHlk3L1gLW62r6hqQ==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.14.tgz",
"integrity": "sha512-tlZEgR2tG+GSNnh2u1SjCxhUHfTDgcr38sE/xRK1bRLDGPZWlr6Ln7qP7JSWqeYBGni75sGrj0iZqcZbPWyJag==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-loader": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-loader": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -709,17 +709,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.1.tgz",
"integrity": "sha512-yef2XB/zR7zjyeUB3Ul0SbTcXqu5isR0GtINkFwL7bJMok6HpYNDnMXSuo55BaxI0dOCnnCSZfoRkAgosnZ1uQ==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz",
"integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -727,16 +727,16 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.1.tgz",
"integrity": "sha512-RJ/kgJsFRdtWlPMXTW1feUSb6WHIvxtNRLgqzX8dlFIoyc4oZex2Vw+URo/VZuWSe/NvCIihQ20rkNAQJMnNMQ==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.14.tgz",
"integrity": "sha512-DVlk6tDgUoDRkp2S4Jc3LrRTuf4DPMlph9vywJw5z6Qpbh0vgcMnObg896/S0Eu5FgACNAj0WGcXpLrcrn5b9Q==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-loader": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-loader": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -744,34 +744,35 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.1.tgz",
"integrity": "sha512-xv96ESa7JgD5z+TzcOK18/u0gq3d9v7QPv2wpr0ZhcnwLwf4sH0eUJZIsv7z7DMOpBNz7o7jJbrtxDUdCEHGhg==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.14.tgz",
"integrity": "sha512-cnwb5dG8Jph8XSArys1WFCQ6kK2R5FKoO0B5mDrHFv9Fcm2pKszlmZC/NDoskX4pgNUgSnwhI1X3cP37ebF9Ng==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-render": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-render": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.1.tgz",
"integrity": "sha512-Q8RF80fb6y9GDAKwvgsu0BsWJlQuhNCtSKWwp3YcZJtIBFm94DVcg0zTgvDmE9/WNOmn4Z1Edt86usmYauHolw==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.14.tgz",
"integrity": "sha512-SaCTo2LdZwGeE6jCqkwJxvwt8YKbsI3QGxa9S7Ez+5OcBchlhHeTfLQswcErDQ3WH2p8WHtGuucAcOLrVVOm0A==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-render": "1.3.1",
"@embedpdf/plugin-scroll": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-render": "1.3.14",
"@embedpdf/plugin-scroll": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -779,15 +780,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.1.tgz",
"integrity": "sha512-gzosrWL18ZhN175Kxocf/p7uqYBhNHvEuV1CpJQmN7ys48aew6Qq8z7MjAsCnJBANXk/8syNdo3qWwBriyjQNg==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz",
"integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
"@embedpdf/models": "1.3.14"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/core": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -795,19 +796,19 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.1.tgz",
"integrity": "sha512-3GXpgv6XmZiQnjaPbsxblTqUn84ALFiyONh2gwrEU9apB6STT3TQiY0QRindwrUXdQLpCSjRSB9PpDBCtTww7w==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.14.tgz",
"integrity": "sha512-/N5tyMk+8OzhObrS3O9yPkcmX8EPiuTo+WaT2QCVSmIUqKnOO4AnKpHJ6Vl0uVhcuXHCMwLucZKyhJ7tRqavwg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1",
"@embedpdf/models": "1.3.14",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-scroll": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"@embedpdf/core": "1.3.14",
"@embedpdf/plugin-interaction-manager": "1.3.14",
"@embedpdf/plugin-scroll": "1.3.14",
"@embedpdf/plugin-viewport": "1.3.14",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -815,9 +816,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.1.tgz",
"integrity": "sha512-6trYysnggwCCTB2q7cX6tkOTbZJNtt2YYZohPCmh0yaDpkfNSgwDwD0jCLtEU2UZLQoH4+2GvNo+4xe+KAGlIQ==",
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.14.tgz",
"integrity": "sha512-gxEJD12nageCMqAjdbicNfDQolXU3nvnV0EX96OdZITRNj0Q1tisutVYoaxcCiJu3vvIEOzipjsAnQOubbFCEA==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
+18 -18
View File
@@ -6,24 +6,24 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.1",
"@embedpdf/engines": "^1.3.1",
"@embedpdf/plugin-annotation": "^1.3.1",
"@embedpdf/plugin-export": "^1.3.1",
"@embedpdf/plugin-history": "^1.3.1",
"@embedpdf/plugin-interaction-manager": "^1.3.1",
"@embedpdf/plugin-loader": "^1.3.1",
"@embedpdf/plugin-pan": "^1.3.1",
"@embedpdf/plugin-render": "^1.3.1",
"@embedpdf/plugin-rotate": "^1.3.1",
"@embedpdf/plugin-scroll": "^1.3.1",
"@embedpdf/plugin-search": "^1.3.1",
"@embedpdf/plugin-selection": "^1.3.1",
"@embedpdf/plugin-spread": "^1.3.1",
"@embedpdf/plugin-thumbnail": "^1.3.1",
"@embedpdf/plugin-tiling": "^1.3.1",
"@embedpdf/plugin-viewport": "^1.3.1",
"@embedpdf/plugin-zoom": "^1.3.1",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
+59 -8
View File
@@ -1,4 +1,21 @@
{
"toolPanel": {
"modePrompt": {
"title": "Choose how you browse tools",
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
"sidebarTitle": "Sidebar mode",
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
"recommended": "Recommended",
"chooseSidebar": "Use sidebar mode",
"fullscreenTitle": "Fullscreen mode - (legacy)",
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
"chooseFullscreen": "Use fullscreen mode",
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
}
},
"unsavedChanges": "You have unsaved changes to your PDF.",
"areYouSure": "Are you sure you want to leave?",
"unsavedChangesTitle": "Unsaved Changes",
@@ -598,11 +615,6 @@
"title": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlayPdfs": {
"tags": "overlay,combine,stack",
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"splitBySections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
@@ -2550,11 +2562,15 @@
"overlay-pdfs": {
"tags": "Overlay",
"header": "Overlay PDF Files",
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files"
"label": "Select Overlay PDF Files",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
},
"mode": {
"label": "Select Overlay Mode",
@@ -2564,14 +2580,49 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"submit": "Submit"
"submit": "Submit",
"settings": {
"title": "Settings"
},
"results": {
"title": "Overlay Results"
},
"tooltip": {
"header": {
"title": "Overlay PDFs Overview"
},
"description": {
"title": "Description",
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
},
"mode": {
"title": "Overlay Mode",
"text": "Choose how to distribute overlay pages across the base PDF pages.",
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
},
"position": {
"title": "Overlay Position",
"text": "Foreground places the overlay on top of the page. Background places it behind."
},
"overlayFiles": {
"title": "Overlay Files",
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
},
"counts": {
"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."
}
}
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize,Customise",
+59 -3
View File
@@ -2,6 +2,23 @@
"language": {
"direction": "ltr"
},
"toolPanel": {
"modePrompt": {
"title": "Choose how you browse tools",
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
"sidebarTitle": "Sidebar mode",
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
"recommended": "Recommended",
"chooseSidebar": "Use sidebar mode",
"fullscreenTitle": "Fullscreen mode - (legacy)",
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
"chooseFullscreen": "Use fullscreen mode",
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
}
},
"addPageNumbers": {
"fontSize": "Font Size",
"fontName": "Font Name",
@@ -1521,11 +1538,15 @@
"overlay-pdfs": {
"tags": "Overlay",
"header": "Overlay PDF Files",
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files"
"label": "Select Overlay PDF Files",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
},
"mode": {
"label": "Select Overlay Mode",
@@ -1535,14 +1556,49 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"submit": "Submit"
"submit": "Submit",
"settings": {
"title": "Settings"
},
"results": {
"title": "Overlay Results"
},
"tooltip": {
"header": {
"title": "Overlay PDFs Overview"
},
"description": {
"title": "Description",
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
},
"mode": {
"title": "Overlay Mode",
"text": "Choose how to distribute overlay pages across the base PDF pages.",
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
},
"position": {
"title": "Overlay Position",
"text": "Foreground places the overlay on top of the page. Background places it behind."
},
"overlayFiles": {
"title": "Overlay Files",
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
},
"counts": {
"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."
}
}
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize",
+3
View File
@@ -9,6 +9,7 @@ import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex';
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
import { loadScript } from '../utils/scriptLoader';
@@ -87,6 +88,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
}, []);
// Preload Google Drive scripts if configured
useEffect(() => {
if (isGoogleDriveConfigured()) {
// Load scripts in parallel without blocking
@@ -125,6 +127,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
styles={{
content: {
position: 'relative',
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack, Group
} from '@mantine/core';
@@ -62,7 +62,7 @@ const FileEditor = ({
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
React.useEffect(() => {
useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
@@ -45,6 +45,7 @@ const FileEditorThumbnail = ({
selectedFiles,
onToggleFile,
onCloseFile,
onViewFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
@@ -205,6 +206,11 @@ const FileEditorThumbnail = ({
onToggleFile(file.id);
};
const handleCardDoubleClick = () => {
if (!isSupported) return;
onViewFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
@@ -226,6 +232,7 @@ const FileEditorThumbnail = ({
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div
@@ -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">
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
@@ -50,7 +50,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
};
// Reset index when selection changes
React.useEffect(() => {
useEffect(() => {
if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0);
}
@@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
</Group>
<Box ml="md">
{sortedHistory.map((historyFile, _index) => (
{sortedHistory.map((historyFile) => (
<FileListItem
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
@@ -56,7 +56,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
onDoubleClick={() => onFileDoubleClick(historyFile)}
isHistoryFile={true} // This enables "Add to Recents" in menu
isLatestVersion={false} // History files are never latest
// onAddToRecents is accessed from context by FileListItem
/>
))}
</Box>
@@ -40,7 +40,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
@@ -188,7 +188,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onAddToRecents(file);
}}
>
{t('fileManager.restore', 'Restore')}
+28 -7
View File
@@ -1,9 +1,12 @@
import React 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 { isBaseWorkbench } from '../../types/workbench';
import { useViewer } from '../../contexts/ViewerContext';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@@ -20,18 +23,19 @@ export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
const { state } = useFileState();
const { selectors } = useFileState();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
const activeFiles = state.files.ids;
const activeFiles = selectors.getFiles();
const {
previewFile,
pageEditorFunctions,
sidebarsVisible,
setPreviewFile,
setPageEditorFunctions,
setSidebarsVisible
setSidebarsVisible,
customWorkbenchViews,
} = useToolWorkflow();
const { handleToolSelect } = useToolWorkflow();
@@ -44,6 +48,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 +102,8 @@ export default function Workbench() {
setSidebarsVisible={setSidebarsVisible}
previewFile={previewFile}
onClose={handlePreviewClose}
activeFileIndex={activeFileIndex}
setActiveFileIndex={setActiveFileIndex}
/>
);
@@ -130,9 +139,14 @@ 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 />;
}
};
@@ -150,6 +164,13 @@ export default function Workbench() {
<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}
/>
)}
@@ -161,7 +182,7 @@ export default function Workbench() {
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',
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
}}
>
{renderMainContent()}
@@ -6,6 +6,7 @@ import Overview from './config/configSections/Overview';
import { createConfigNavSections } from './config/configNavSections';
import { NavKey } from './config/types';
import './AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
interface AppConfigModalProps {
opened: boolean;
@@ -77,7 +78,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
centered
radius="lg"
withCloseButton={false}
style={{ zIndex: 1000 }}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { Box } from '@mantine/core';
interface BadgeProps {
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'colored';
color?: string;
textColor?: string;
backgroundColor?: string;
className?: string;
style?: React.CSSProperties;
}
const Badge: React.FC<BadgeProps> = ({
children,
size = 'sm',
variant = 'default',
color,
textColor,
backgroundColor,
className,
style
}) => {
const getSizeStyles = () => {
switch (size) {
case 'sm':
return {
padding: '0.125rem 0.5rem',
fontSize: '0.75rem',
fontWeight: 700,
borderRadius: '0.5rem',
};
case 'md':
return {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
fontWeight: 700,
borderRadius: '0.625rem',
};
case 'lg':
return {
padding: '0.375rem 1rem',
fontSize: '1rem',
fontWeight: 700,
borderRadius: '0.75rem',
};
default:
return {};
}
};
const getVariantStyles = () => {
// If explicit colors are provided, use them
if (textColor && backgroundColor) {
return {
backgroundColor,
color: textColor,
};
}
// If a single color is provided, use it for text and 20% opacity for background
if (color) {
return {
backgroundColor: `color-mix(in srgb, ${color} 20%, transparent)`,
color: color,
};
}
// If variant is colored but no color provided, use default colored styling
if (variant === 'colored') {
return {
backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`,
color: 'var(--category-color-default)',
borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`,
border: '1px solid',
};
}
// Default styling
return {
background: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
};
};
return (
<Box
className={className}
style={{
...getSizeStyles(),
...getVariantStyles(),
...style,
}}
>
{children}
</Box>
);
};
export default Badge;
@@ -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 style={{...viewOptionStyle, cursor: 'pointer'}}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} className="ph-no-capture" />
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
<Menu.Dropdown 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"
{...(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 text={itemName} fontSize={14} minimumFontScale={0.7} className="ph-no-capture" />
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed">
v{file.versionNumber}
</Text>
)}
</Group>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
};
@@ -125,6 +125,7 @@ const FilePickerModal = ({
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
zIndex={1100}
>
<Stack gap="md">
{storedFiles.length === 0 ? (
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
// Types
interface LanguageSelectorProps {
@@ -209,6 +210,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
width={600}
position={position}
offset={offset}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
transitionProps={{
transition: 'scale-y',
duration: 200,
@@ -264,6 +266,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<ScrollArea h={190} type="scroll">
@@ -24,7 +24,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const [configModalOpen, setConfigModalOpen] = useState(false);
@@ -68,7 +68,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick()
onClick: () => handleClick(),
'aria-label': config.name
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
@@ -98,12 +99,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
type: 'navigation',
onClick: () => {
setActiveButton('read');
handleBackToTools();
handleReaderToggle();
}
},
// TODO: Add sign
//{
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
@@ -114,7 +113,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
//},
// },
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
@@ -230,6 +229,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
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">
+52 -34
View File
@@ -20,24 +20,30 @@ import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
import { useSidebarContext } from '../../contexts/SidebarContext';
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 } = useRightRail();
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 } = useToolWorkflow();
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();
const isCustomWorkbench = typeof currentView === 'string' && currentView.startsWith('custom:');
// File state and selection
const { state, selectors } = useFileState();
@@ -176,19 +182,19 @@ export default function RightRail() {
}, [currentView]);
return (
<div className="right-rail">
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
<div className="right-rail-inner">
{topButtons.length > 0 && (
{topButtons.length > 0 && !isCustomWorkbench && (
<>
<div className="right-rail-section">
{topButtons.map(btn => (
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
<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}
disabled={btn.disabled || allButtonsDisabled || disableForFullscreen}
>
{btn.icon}
</ActionIcon>
@@ -200,13 +206,14 @@ export default function RightRail() {
)}
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
{!isCustomWorkbench && (
<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>
<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' }}>
@@ -214,7 +221,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={currentView !== 'viewer'}
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" />
@@ -234,7 +241,7 @@ export default function RightRail() {
{/* Pan Mode */}
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanning ? "filled" : "subtle"}
color={isPanning ? "blue" : undefined}
@@ -244,14 +251,14 @@ export default function RightRail() {
viewerContext?.panActions.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer'}
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>
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -259,14 +266,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateBackward();
}}
disabled={currentView !== 'viewer'}
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>
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -274,14 +281,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateForward();
}}
disabled={currentView !== 'viewer'}
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>
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -289,33 +296,38 @@ export default function RightRail() {
onClick={() => {
viewerContext?.toggleThumbnailSidebar();
}}
disabled={currentView !== 'viewer'}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls currentView={currentView} />
<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 */}
{!isCustomWorkbench && (
<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>
<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}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -323,14 +335,14 @@ export default function RightRail() {
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<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}
disabled={currentView === 'viewer' || selectedCount === 0 || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -339,7 +351,7 @@ export default function RightRail() {
{/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
<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}>
@@ -349,7 +361,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0}
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" />
@@ -376,7 +388,7 @@ export default function RightRail() {
{/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
<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' }}>
@@ -385,7 +397,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
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" />
@@ -398,7 +410,7 @@ export default function RightRail() {
{/* Export Selected Pages - page editor only */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
<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
@@ -406,7 +418,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
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" />
@@ -417,7 +429,7 @@ export default function RightRail() {
)}
{/* 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>
<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"
@@ -427,7 +439,8 @@ export default function RightRail() {
disabled={
currentView === 'viewer' ||
(currentView === 'fileEditor' && selectedCount === 0) ||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) ||
allButtonsDisabled || disableForFullscreen
}
>
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
@@ -438,10 +451,12 @@ export default function RightRail() {
<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>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow portalTarget={document.body}
>
<ActionIcon
variant="subtle"
radius="md"
@@ -452,13 +467,17 @@ export default function RightRail() {
</ActionIcon>
</Tooltip>
<LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={t('rightRail.language', 'Language')} position="left" offset={12} arrow portalTarget={document.body}>
<div style={{ display: 'inline-flex' }}>
<LanguageSelector position="left-start" offset={6} compact />
</div>
</Tooltip>
<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>
} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
@@ -466,7 +485,7 @@ export default function RightRail() {
className="right-rail-icon"
onClick={handleExportAll}
disabled={
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
disableForFullscreen || (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
@@ -481,4 +500,3 @@ export default function RightRail() {
);
}
+22 -10
View File
@@ -8,6 +8,7 @@ import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import { BASE_PATH } from '../../constants/app';
import styles from './tooltip/Tooltip.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
export interface TooltipProps {
sidebarTooltip?: boolean;
@@ -28,6 +29,10 @@ export interface TooltipProps {
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
/** If true, tooltip interaction is disabled entirely */
disabled?: boolean;
/** If false, tooltip will not open on focus (hover only) */
openOnFocus?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@@ -48,6 +53,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
disabled = false,
openOnFocus = true,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
@@ -68,7 +75,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
const isControlled = controlledOpen !== undefined;
const open = isControlled ? !!controlledOpen : internalOpen;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const setOpen = useCallback(
(newOpen: boolean) => {
@@ -148,15 +155,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
// === Trigger handlers ===
const openWithDelay = useCallback(() => {
clearTimers();
if (disabled) return;
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
}, [clearTimers, setOpen, delay]);
}, [clearTimers, setOpen, delay, disabled]);
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned) openWithDelay();
if (!isPinned && !disabled) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props]
[isPinned, openWithDelay, children.props, disabled]
);
const handlePointerLeave = useCallback(
@@ -219,10 +227,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned) openWithDelay();
if (!isPinned && !disabled && openOnFocus) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props]
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
);
const handleBlur = useCallback(
@@ -291,7 +299,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: 9999,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
color: 'var(--text-primary)',
@@ -345,9 +353,13 @@ export const Tooltip: React.FC<TooltipProps> = ({
return (
<>
{childWithHandlers}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
{(() => {
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
const target = portalTarget ?? defaultTarget;
return tooltipElement && target
? createPortal(tooltipElement, target)
: tooltipElement;
})()}
</>
);
};
+62 -11
View File
@@ -5,10 +5,13 @@ 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 { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import type { CustomWorkbenchViewInstance } from '../../contexts/ToolWorkflowContext';
import { FileDropdownMenu } from './FileDropdownMenu';
const viewOptionStyle = {
const viewOptionStyle: React.CSSProperties = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
@@ -19,16 +22,39 @@ const viewOptionStyle = {
// Build view options showing text always
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
const createViewOptions = (
currentView: WorkbenchType,
switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void,
customViews?: CustomWorkbenchViewInstance[]
) => {
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || '';
const displayName = isInViewer && fileName ? fileName : 'Viewer';
const hasMultipleFiles = activeFiles.length > 1;
const showDropdown = isInViewer && hasMultipleFiles;
const viewerOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
label: showDropdown ? (
<FileDropdownMenu
displayName={displayName}
activeFiles={activeFiles}
currentFileIndex={currentFileIndex}
onFileSelect={onFileSelect}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<span>Viewer</span>
<span className="ph-no-capture">{displayName}</span>
</div>
),
value: "viewer",
@@ -36,7 +62,7 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
const pageEditorOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
<div style={viewOptionStyle}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
@@ -55,7 +81,7 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
const fileEditorOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
<div style={viewOptionStyle}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
@@ -72,23 +98,48 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
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="xs" />
) : (
view.icon || <PictureAsPdfIcon fontSize="small" />
)}
<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);
@@ -118,7 +169,7 @@ const TopControls = ({
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(currentView, switchingTo)}
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect)}
value={currentView}
onChange={handleViewChange}
color="blue"
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core';
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';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
@@ -26,6 +27,24 @@ const GeneralSection: React.FC = () => {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
@@ -1,11 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text } from '@mantine/core';
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
import { useHotkeys } from '../../../../contexts/HotkeyContext';
import { ToolId } from '../../../../types/toolId';
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys';
import { ToolId } from 'src/types/toolId';
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
const rowStyle: React.CSSProperties = {
@@ -28,9 +28,21 @@ const HotkeysSection: React.FC = () => {
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
const filteredTools = useMemo(() => {
if (!searchQuery.trim()) return tools;
const query = searchQuery.toLowerCase();
return tools.filter(([toolId, tool]) =>
tool.name.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query) ||
toolId.toLowerCase().includes(query)
);
}, [tools, searchQuery]);
useEffect(() => {
if (!editingTool) {
return;
@@ -47,15 +59,17 @@ const HotkeysSection: React.FC = () => {
}
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setEditingTool(null);
setError(null);
return;
}
event.preventDefault();
event.stopPropagation();
const binding = eventToBinding(event as KeyboardEvent);
if (!binding) {
const osKey = isMac ? 'mac' : 'windows';
@@ -71,7 +85,10 @@ const HotkeysSection: React.FC = () => {
));
if (conflictEntry) {
const conflictTool = toolRegistry[conflictEntry[0]]?.name ?? conflictEntry[0];
const conflictKey = conflictEntry[0];
const conflictTool = (conflictKey in toolRegistry)
? toolRegistry[conflictKey as ToolId]?.name
: conflictKey;
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
return;
}
@@ -101,9 +118,22 @@ const HotkeysSection: React.FC = () => {
</Text>
</div>
<TextInput
placeholder={t('settings.hotkeys.searchPlaceholder', 'Search tools...')}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
size="md"
radius="md"
/>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{tools.map(([toolId, tool], index) => {
{filteredTools.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('toolPicker.noToolsFound', 'No tools found')}
</Text>
) : (
filteredTools.map(([toolId, tool], index) => {
const currentBinding = hotkeys[toolId];
const defaultBinding = defaults[toolId];
const isEditing = editingTool === toolId;
@@ -160,10 +190,11 @@ const HotkeysSection: React.FC = () => {
)}
</Box>
{index < tools.length - 1 && <Divider />}
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})}
})
)}
</Stack>
</Paper>
</Stack>
@@ -46,6 +46,13 @@
background-color: transparent !important;
}
/* When all buttons are disabled via context */
.right-rail--all-disabled .right-rail-icon {
color: var(--right-rail-icon-disabled) !important;
background-color: transparent !important;
pointer-events: none !important;
}
.right-rail-spacer {
flex: 1;
}
@@ -14,9 +14,10 @@ import { useNavigationState } from '../../../contexts/NavigationContext';
interface ViewerAnnotationControlsProps {
currentView: string;
disabled?: boolean;
}
export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) {
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@@ -52,7 +53,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -60,7 +61,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -104,7 +105,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer'}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
@@ -129,7 +130,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -146,7 +147,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer'}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
@@ -155,7 +156,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -203,7 +204,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer'}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -0,0 +1,253 @@
import React, { useMemo } from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ToolRegistryEntry, getSubcategoryLabel, getSubcategoryColor, getSubcategoryIcon } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import { useToolSections } from '../../hooks/useToolSections';
import NoToolsFound from './shared/NoToolsFound';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import ThumbUpRoundedIcon from '@mui/icons-material/ThumbUpRounded';
import Badge from '../shared/Badge';
import './ToolPanel.css';
import DetailedToolItem from './fullscreen/DetailedToolItem';
import CompactToolItem from './fullscreen/CompactToolItem';
import { useFavoriteToolItems } from '../../hooks/tools/useFavoriteToolItems';
interface FullscreenToolListProps {
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
searchQuery: string;
showDescriptions: boolean;
selectedToolKey: string | null;
matchedTextMap: Map<string, string>;
onSelect: (id: ToolId) => void;
}
const FullscreenToolList = ({
filteredTools,
searchQuery,
showDescriptions,
selectedToolKey,
matchedTextMap: _matchedTextMap,
onSelect,
}: FullscreenToolListProps) => {
const { t } = useTranslation();
const { toolRegistry, favoriteTools } = useToolWorkflow();
const { sections, searchGroups } = useToolSections(filteredTools, searchQuery);
const tooltipPortalTarget = typeof document !== 'undefined' ? document.body : undefined;
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const quickSection = useMemo(() => sections.find(section => section.key === 'quick'), [sections]);
const recommendedItems = useMemo(() => {
if (!quickSection) return [] as Array<{ id: string, tool: ToolRegistryEntry }>;
const items: Array<{ id: string, tool: ToolRegistryEntry }> = [];
quickSection.subcategories.forEach(sc => sc.tools.forEach(t => items.push(t)));
return items;
}, [quickSection]);
// Show recommended/favorites section only when not searching
const showRecentFavorites = searchQuery.trim().length === 0 && ((recommendedItems.length > 0) || favoriteToolItems.length > 0);
const subcategoryGroups = useMemo(() => {
if (searchQuery.trim().length > 0) {
return searchGroups;
}
const allSection = sections.find(section => section.key === 'all');
return allSection ? allSection.subcategories : [];
}, [searchGroups, sections, searchQuery]);
if (subcategoryGroups.length === 0 && !showRecentFavorites) {
return (
<div className="tool-panel__fullscreen-empty">
<NoToolsFound />
<Text size="sm" c="dimmed">
{t('toolPanel.fullscreen.noResults', 'Try adjusting your search or toggle descriptions to find what you need.')}
</Text>
</div>
);
}
const containerClass = showDescriptions
? 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--detailed'
: 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--compact';
// Helper function to render a tool item
const renderToolItem = (id: ToolId, tool: ToolRegistryEntry) => {
const isSelected = selectedToolKey === id;
const handleClick = () => {
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') return;
if (tool.link) {
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
}
onSelect(id as ToolId);
};
if (showDescriptions) {
return (
<DetailedToolItem key={id} id={id} tool={tool} isSelected={isSelected} onClick={handleClick} />
);
}
return (
<CompactToolItem key={id} id={id} tool={tool} isSelected={isSelected} onClick={handleClick} tooltipPortalTarget={tooltipPortalTarget} />
);
};
return (
<div className={containerClass}>
{showRecentFavorites && (
<>
{favoriteToolItems.length > 0 && (
<section
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
style={{
borderColor: 'var(--fullscreen-border-favorites)',
}}
>
<header className="tool-panel__fullscreen-section-header">
<div className="tool-panel__fullscreen-section-title">
<span
className="tool-panel__fullscreen-section-icon"
style={{
color: 'var(--special-color-favorites)',
}}
aria-hidden
>
<StarRoundedIcon />
</span>
<Text size="sm" fw={600} tt="uppercase" lts={0.5} c="dimmed">
{t('toolPanel.fullscreen.favorites', 'Favourites')}
</Text>
</div>
<Badge
size="sm"
variant="colored"
color="var(--special-color-favorites)"
>
{favoriteToolItems.length}
</Badge>
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))}
</div>
)}
</section>
)}
{recommendedItems.length > 0 && (
<section
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
style={{
borderColor: 'var(--fullscreen-border-recommended)',
}}
>
<header className="tool-panel__fullscreen-section-header">
<div className="tool-panel__fullscreen-section-title">
<span
className="tool-panel__fullscreen-section-icon"
style={{
color: 'var(--special-color-recommended)',
}}
aria-hidden
>
<ThumbUpRoundedIcon />
</span>
<Text size="sm" fw={600} tt="uppercase" lts={0.5} c="dimmed">
{t('toolPanel.fullscreen.recommended', 'Recommended')}
</Text>
</div>
<Badge
size="sm"
variant="colored"
color="var(--special-color-recommended)"
>
{recommendedItems.length}
</Badge>
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))}
</div>
)}
</section>
)}
</>
)}
{subcategoryGroups.map(({ subcategoryId, tools }) => {
const categoryColor = getSubcategoryColor(subcategoryId);
return (
<section
key={subcategoryId}
className={`tool-panel__fullscreen-group ${showDescriptions ? 'tool-panel__fullscreen-group--detailed' : 'tool-panel__fullscreen-group--compact'}`}
style={{
borderColor: `color-mix(in srgb, ${categoryColor} 25%, var(--fullscreen-border-subtle-65))`,
}}
>
<header className="tool-panel__fullscreen-section-header">
<div className="tool-panel__fullscreen-section-title">
<span
className="tool-panel__fullscreen-section-icon"
style={{
color: categoryColor,
}}
aria-hidden
>
{getSubcategoryIcon(subcategoryId)}
</span>
<Text
size="sm"
fw={600}
tt="uppercase"
lts={0.5}
style={{
color: categoryColor,
}}
>
{getSubcategoryLabel(t, subcategoryId)}
</Text>
</div>
<Badge
size="sm"
variant="colored"
color={categoryColor}
>
{tools.length}
</Badge>
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))}
</div>
)}
</section>
);
})}
</div>
);
};
export default FullscreenToolList;
@@ -0,0 +1,159 @@
import { useState, useRef } from 'react';
import { ActionIcon, ScrollArea, Switch, useMantineColorScheme } from '@mantine/core';
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import { useTranslation } from 'react-i18next';
import ToolSearch from './toolPicker/ToolSearch';
import FullscreenToolList from './FullscreenToolList';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { BASE_PATH } from '../../constants/app';
import { Tooltip } from '../shared/Tooltip';
import './ToolPanel.css';
import { ToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
interface FullscreenToolSurfaceProps {
searchQuery: string;
toolRegistry: Partial<Record<ToolId, ToolRegistryEntry>>;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
selectedToolKey: string | null;
showDescriptions: boolean;
matchedTextMap: Map<string, string>;
onSearchChange: (value: string) => void;
onSelect: (id: ToolId) => void;
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
toggleLabel: string;
geometry: ToolPanelGeometry | null;
}
const FullscreenToolSurface = ({
searchQuery,
toolRegistry,
filteredTools,
selectedToolKey,
showDescriptions,
matchedTextMap,
onSearchChange,
onSelect,
onToggleDescriptions,
onExitFullscreenMode,
toggleLabel,
geometry,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const { colorScheme } = useMantineColorScheme();
const [isExiting, setIsExiting] = useState(false);
const surfaceRef = useRef<HTMLDivElement>(null);
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, !isExiting);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
const handleExit = () => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
onExitFullscreenMode();
return;
}
setIsExiting(true);
const el = surfaceRef.current;
if (!el) {
onExitFullscreenMode();
return;
}
// Rely on CSS animation end rather than duplicating timing in JS
el.addEventListener('animationend', () => {
onExitFullscreenMode();
}, { once: true });
};
const style = geometry
? {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
}
: undefined;
return (
<div
className="tool-panel__fullscreen-surface"
style={style}
role="region"
aria-label={t('toolPanel.fullscreen.heading', 'All tools (fullscreen view)')}
>
<div
ref={surfaceRef}
className={`tool-panel__fullscreen-surface-inner ${isExiting ? 'tool-panel__fullscreen-surface-inner--exiting' : ''}`}
>
<header className="tool-panel__fullscreen-header">
<div className="tool-panel__fullscreen-brand">
<img src={brandIconSrc} alt="" className="tool-panel__fullscreen-brand-icon" />
<img src={brandTextSrc} alt={brandAltText} className="tool-panel__fullscreen-brand-text" />
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Tooltip content={toggleLabel} position="bottom" arrow={true} openOnFocus={false} containerStyle={{ zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE }}>
<ActionIcon
variant="subtle"
radius="xl"
size="md"
onClick={handleExit}
aria-label={toggleLabel}
style={{ color: 'var(--right-rail-icon)' }}
>
<DoubleArrowIcon fontSize="small" style={{ transform: 'rotate(180deg)' }} />
</ActionIcon>
</Tooltip>
</div>
</header>
<div className="tool-panel__fullscreen-controls">
<ToolSearch
value={searchQuery}
onChange={onSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus
/>
<Switch
checked={showDescriptions}
onChange={() => onToggleDescriptions()}
size="md"
labelPosition="left"
label={t('toolPanel.fullscreen.showDetails', 'Show Details')}
/>
</div>
<div className="tool-panel__fullscreen-body">
<ScrollArea className="tool-panel__fullscreen-scroll" offsetScrollbars>
<FullscreenToolList
filteredTools={filteredTools}
searchQuery={searchQuery}
showDescriptions={showDescriptions}
selectedToolKey={selectedToolKey}
matchedTextMap={matchedTextMap}
onSelect={onSelect}
/>
</ScrollArea>
</div>
</div>
</div>
);
};
export default FullscreenToolSurface;
@@ -1,13 +1,13 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
import { useToolSections } from '../../hooks/useToolSections';
import SubcategoryHeader from './shared/SubcategoryHeader';
import NoToolsFound from './shared/NoToolsFound';
import "./toolPicker/ToolPicker.css";
import { ToolId } from 'src/types/toolId';
interface SearchResultsProps {
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
+548
View File
@@ -0,0 +1,548 @@
/* CSS Custom Properties for Fullscreen Mode */
.tool-panel__fullscreen-surface-inner {
--fullscreen-bg-surface-1: color-mix(in srgb, var(--bg-toolbar) 96%, transparent);
--fullscreen-bg-surface-2: color-mix(in srgb, var(--bg-background) 90%, transparent);
--fullscreen-bg-header: var(--bg-toolbar);
--fullscreen-bg-controls-1: var(--bg-toolbar);
--fullscreen-bg-controls-2: color-mix(in srgb, var(--bg-toolbar) 95%, var(--bg-background));
--fullscreen-bg-body-1: color-mix(in srgb, var(--bg-background) 86%, transparent);
--fullscreen-bg-body-2: color-mix(in srgb, var(--bg-toolbar) 78%, transparent);
--fullscreen-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent);
--fullscreen-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent);
--fullscreen-bg-list-item: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
--fullscreen-bg-icon-detailed: color-mix(in srgb, var(--bg-muted) 75%, transparent);
--fullscreen-bg-icon-compact: color-mix(in srgb, var(--bg-muted) 70%, transparent);
--fullscreen-border-subtle-75: color-mix(in srgb, var(--border-subtle) 75%, transparent);
--fullscreen-border-subtle-70: color-mix(in srgb, var(--border-subtle) 70%, transparent);
--fullscreen-border-subtle-65: color-mix(in srgb, var(--border-subtle) 65%, transparent);
--fullscreen-border-favorites: color-mix(in srgb, var(--special-color-favorites) 25%, var(--border-subtle));
--fullscreen-border-recommended: color-mix(in srgb, var(--special-color-recommended) 25%, var(--border-subtle));
--fullscreen-shadow-primary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent);
--fullscreen-shadow-secondary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent);
--fullscreen-shadow-group: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent);
--fullscreen-accent-hover: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
--fullscreen-accent-selected: color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
--fullscreen-accent-ring: color-mix(in srgb, var(--text-primary) 15%, transparent);
--fullscreen-accent-list-bg: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar));
--fullscreen-accent-list-border: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
--fullscreen-text-icon: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted));
--fullscreen-text-icon-compact: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted));
}
.tool-panel {
position: relative;
transition: width 0.3s ease, max-width 0.3s ease;
}
.tool-panel--fullscreen-active {
overflow: visible !important;
}
.tool-panel__search-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.tool-panel__search-row .search-input-container {
flex: 1 1 auto;
}
.tool-panel__mode-toggle {
transition: transform 0.2s ease;
}
.tool-panel__mode-toggle:hover {
transform: scale(1.04);
}
.tool-panel--fullscreen {
background: var(--bg-toolbar);
}
.tool-panel__placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.9rem;
padding: 1.5rem;
text-align: center;
}
.tool-panel__fullscreen-surface {
position: fixed;
display: flex;
pointer-events: none;
z-index: var(--z-fullscreen-surface);
top: 0;
left: 0;
width: 0;
height: 0;
}
.tool-panel__fullscreen-surface-inner {
pointer-events: auto;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
border-radius: 0;
background:
linear-gradient(
140deg,
var(--fullscreen-bg-surface-1),
var(--fullscreen-bg-surface-2)
)
padding-box;
border: 1px solid var(--fullscreen-border-subtle-75);
box-shadow: none;
backdrop-filter: blur(18px);
overflow: hidden;
/* Shared animation durations for JS + CSS (sourced from theme.css) */
--fullscreen-anim-in-duration: var(--fullscreen-anim-duration-in);
--fullscreen-anim-out-duration: var(--fullscreen-anim-duration-out);
animation: tool-panel-fullscreen-slide-in var(--fullscreen-anim-in-duration) ease forwards;
}
.tool-panel__fullscreen-surface-inner--exiting {
animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) ease forwards;
}
.tool-panel__fullscreen-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.75rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
}
.tool-panel__fullscreen-brand {
display: flex;
align-items: center;
gap: 0.625rem;
}
.tool-panel__fullscreen-brand-icon {
height: 1.75rem;
width: auto;
}
.tool-panel__fullscreen-brand-text {
height: 1.25rem;
width: auto;
}
.tool-panel__fullscreen-controls {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.75rem;
border-bottom: 1px solid var(--tool-panel-search-border-bottom);
background: var(--tool-panel-search-bg);
}
.tool-panel__fullscreen-controls .search-input-container {
flex: 1 1 auto;
}
.tool-panel__fullscreen-body {
flex: 1;
min-height: 0;
background: transparent;
}
.tool-panel__fullscreen-scroll {
height: 100%;
}
/* fullscreen group layout */
.tool-panel__fullscreen-groups {
padding: 1.5rem 1.75rem;
column-width: 18rem;
column-gap: 1.5rem;
}
.tool-panel__fullscreen-groups--compact {
column-width: 17rem;
}
.tool-panel__fullscreen-groups--detailed {
column-width: auto;
}
.tool-panel__fullscreen-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
margin: 0 0 1.5rem;
padding: 0.65rem 0.75rem 1rem;
border-radius: 1rem;
background: var(--fullscreen-bg-group);
border: 1px solid var(--fullscreen-border-subtle-65);
box-shadow: 0 14px 32px var(--fullscreen-shadow-group);
break-inside: avoid;
backdrop-filter: blur(10px);
}
.tool-panel__fullscreen-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.1rem 0.15rem 0.35rem;
}
.tool-panel__fullscreen-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tool-panel__fullscreen-section-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.tool-panel__fullscreen-grid {
display: grid;
gap: 0.75rem;
}
.tool-panel__fullscreen-grid--detailed {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
}
.tool-panel__fullscreen-item {
all: unset;
display: flex;
flex-direction: row;
gap: 0.75rem;
align-items: flex-start;
padding: 0.85rem 0.95rem;
border: 1px solid var(--fullscreen-border-subtle-70);
border-radius: 0.95rem;
background: var(--fullscreen-bg-item);
backdrop-filter: blur(6px);
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
width: 100%;
box-sizing: border-box;
/* Allow flex children to shrink without forcing horizontal overflow */
min-width: 0;
}
.tool-panel__fullscreen-item:focus-visible {
outline: 2px solid var(--fullscreen-accent-selected);
outline-offset: 3px;
}
.tool-panel__fullscreen-item[aria-disabled="true"],
.tool-panel__fullscreen-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]):not(:disabled) {
transform: translateY(-2px);
border-color: var(--fullscreen-accent-hover);
box-shadow: var(--shadow-xl, 0 18px 34px rgba(15, 23, 42, 0.14));
}
.tool-panel__fullscreen-item--selected {
border-color: var(--fullscreen-accent-selected);
box-shadow: 0 0 0 2px var(--fullscreen-accent-ring);
}
.tool-panel__fullscreen-item--detailed {
min-height: 7.5rem;
}
.tool-panel__fullscreen-item--with-star {
position: relative;
}
.tool-panel__fullscreen-star {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.tool-panel__fullscreen-item:hover .tool-panel__fullscreen-star {
opacity: 1;
}
.tool-panel__fullscreen-list-item--with-star {
position: relative;
}
.tool-panel__fullscreen-star-compact {
position: absolute;
top: 0.35rem;
right: 0.35rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.tool-panel__fullscreen-list-item:hover .tool-panel__fullscreen-star-compact {
opacity: 1;
}
.tool-panel__fullscreen-group--special {
border-width: 1px;
}
.tool-panel__fullscreen-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
background: var(--fullscreen-bg-icon-detailed);
color: var(--fullscreen-text-icon);
flex-shrink: 0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.tool-panel__fullscreen-icon::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg,
color-mix(in srgb, var(--text-primary) 12%, transparent),
color-mix(in srgb, var(--text-primary) 4%, transparent)
);
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 0.75rem;
}
.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon {
transform: scale(1.08);
box-shadow: 0 4px 12px color-mix(in srgb, var(--text-primary) 15%, transparent);
}
.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon::before {
opacity: 1;
}
.tool-panel__fullscreen-icon svg {
font-size: 1.65rem;
position: relative;
z-index: var(--z-fullscreen-icon-svg);
}
.tool-panel__fullscreen-body {
display: flex;
flex-direction: column;
gap: 0.4rem;
text-align: left;
/* Prevent long content (names, descriptions, shortcuts) from overflowing */
flex: 1 1 auto;
min-width: 0;
}
.tool-panel__fullscreen-name {
color: var(--text-primary);
}
.tool-panel__fullscreen-description {
line-height: 1.45;
}
/* Ensure long words or unbroken strings wrap instead of overflowing */
.tool-panel__fullscreen-name,
.tool-panel__fullscreen-description,
.tool-panel__fullscreen-match {
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.tool-panel__fullscreen-match {
font-style: italic;
}
.tool-panel__fullscreen-shortcut {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.2rem;
/* Allow hotkey chips to wrap on small widths to avoid overflow */
flex-wrap: wrap;
row-gap: 0.25rem;
}
.tool-panel__fullscreen-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.tool-panel__fullscreen-list-item {
all: unset;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.5rem 0.55rem 0.65rem;
border-radius: 0.65rem;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
background: var(--fullscreen-bg-list-item);
border: 1px solid transparent;
width: 100%;
box-sizing: border-box;
position: relative;
}
.tool-panel__fullscreen-list-item[aria-disabled="true"],
.tool-panel__fullscreen-list-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]):not(:disabled),
.tool-panel__fullscreen-list-item--selected {
background: var(--fullscreen-accent-list-bg);
border-color: var(--fullscreen-accent-list-border);
}
.tool-panel__fullscreen-list-item--selected {
transform: translateX(2px);
}
.tool-panel__fullscreen-list-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.6rem;
background: var(--fullscreen-bg-icon-compact);
color: var(--fullscreen-text-icon-compact);
flex-shrink: 0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.tool-panel__fullscreen-list-icon::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg,
color-mix(in srgb, var(--text-primary) 10%, transparent),
color-mix(in srgb, var(--text-primary) 3%, transparent)
);
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 0.6rem;
}
.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon {
transform: scale(1.06);
box-shadow: 0 2px 8px color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon::before {
opacity: 1;
}
.tool-panel__fullscreen-list-icon svg {
position: relative;
z-index: 1;
}
.tool-panel__fullscreen-list-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
text-align: left;
}
.tool-panel__fullscreen-empty {
padding: 2rem 1.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@keyframes tool-panel-fullscreen-slide-in {
from {
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
}
to {
transform: translateX(0) scaleX(1);
opacity: 1;
}
}
@keyframes tool-panel-fullscreen-slide-out {
from {
transform: translateX(0) scaleX(1);
opacity: 1;
}
to {
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.tool-panel__fullscreen-surface-inner {
animation: none !important;
}
.tool-panel__mode-toggle {
transition: none;
}
.tool-panel__fullscreen-item,
.tool-panel__fullscreen-list-item {
transition: none;
}
}
@media (max-width: 1440px) {
.tool-panel__fullscreen-content {
padding-inline: 1.5rem;
}
.tool-panel__fullscreen-grid--compact {
column-width: 15rem;
}
}
@media (max-width: 1280px) {
.tool-panel__fullscreen-controls {
flex-direction: column;
align-items: stretch;
}
.tool-panel__fullscreen-controls .mantine-Switch-root {
justify-content: flex-end;
}
.tool-panel__fullscreen-grid--compact {
column-width: 14rem;
}
}
+168 -63
View File
@@ -1,3 +1,4 @@
import React, { useEffect, useMemo } from 'react';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
@@ -6,20 +7,27 @@ import ToolRenderer from './ToolRenderer';
import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
import { ScrollArea } from '@mantine/core';
import { ActionIcon, ScrollArea } from '@mantine/core';
import { ToolId } from '../../types/toolId';
import { useMediaQuery } from '@mantine/hooks';
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';
// No props needed - component uses context
export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
// Use context-based hooks to eliminate prop drilling
const {
leftPanelView,
isPanelVisible,
@@ -27,84 +35,181 @@ export default function ToolPanel() {
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
handleToolSelect,
setPreviewFile,
toolPanelMode,
setToolPanelMode,
setLeftPanelView,
readerMode,
} = useToolWorkflow();
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
const { setPreviewFile } = useToolWorkflow();
const { setAllRightRailButtonsDisabled } = useRightRail();
const isFullscreenMode = toolPanelMode === 'fullscreen';
const toolPickerVisible = !readerMode;
const fullscreenExpanded = isFullscreenMode && leftPanelView === 'toolPicker' && !isMobile && toolPickerVisible;
// Disable right rail buttons when fullscreen mode is active
useEffect(() => {
setAllRightRailButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllRightRailButtonsDisabled]);
// Use custom hooks for state management
const [showLegacyDescriptions, setShowLegacyDescriptions] = useLocalStorageState('legacyToolDescriptions', false);
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
rightRailRef,
});
const toggleLabel = isFullscreenMode
? t('toolPanel.toggle.sidebar', 'Switch to sidebar mode')
: t('toolPanel.toggle.fullscreen', 'Switch to fullscreen mode');
const handleModeToggle = () => {
const nextMode = isFullscreenMode ? 'sidebar' : 'fullscreen';
setToolPanelMode(nextMode);
if (nextMode === 'fullscreen' && leftPanelView !== 'toolPicker') {
setLeftPanelView('toolPicker');
}
};
const computedWidth = () => {
if (isMobile) {
return '100%';
}
if (!isPanelVisible) {
return '0';
}
return '18.5rem';
};
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) {
map.set(id, matchedText);
}
});
return map;
}, [filteredTools]);
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
className={`flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
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'}`}
} ${isMobile ? 'h-full border-r-0' : 'h-screen'} ${fullscreenExpanded ? 'tool-panel--fullscreen' : ''}`}
style={{
width: isMobile ? '100%' : isPanelVisible ? '18.5rem' : '0',
width: computedWidth(),
padding: '0'
}}
>
<div
style={{
opacity: isMobile || isPanelVisible ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{/* Search Bar - Always visible at the top */}
{!fullscreenExpanded && (
<div
style={{
backgroundColor: 'var(--tool-panel-search-bg)',
borderBottom: '1px solid var(--tool-panel-search-border-bottom)',
padding: '0.75rem 1rem',
opacity: isMobile || isPanelVisible ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
{searchQuery.trim().length > 0 ? (
// Searching view (replaces both picker and content)
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (
// Tool Picker View
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
<div
className="tool-panel__search-row"
style={{
backgroundColor: 'var(--tool-panel-search-bg)',
borderBottom: '1px solid var(--tool-panel-search-border-bottom)'
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
{!isMobile && leftPanelView === 'toolPicker' && (
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
openOnFocus={false}
>
<ActionIcon
variant="subtle"
radius="xl"
style={{ color: 'var(--right-rail-icon)' }}
onClick={handleModeToggle}
aria-label={toggleLabel}
className="tool-panel__mode-toggle"
>
<DoubleArrowIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
</div>
) : (
// Selected Tool Content View
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tool content */}
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey && (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
)}
</ScrollArea>
{searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t('toolPanel.placeholder', 'Choose a tool to get started')}
</div>
)}
</ScrollArea>
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{fullscreenExpanded && (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={showLegacyDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() => setShowLegacyDescriptions((prev) => !prev)}
onExitFullscreenMode={() => setToolPanelMode('sidebar')}
toggleLabel={toggleLabel}
geometry={fullscreenGeometry}
/>
)}
</div>
);
}
@@ -0,0 +1,200 @@
.tool-panel-mode-prompt__modal {
background: color-mix(in srgb, var(--bg-toolbar) 94%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
box-shadow: 0 32px 64px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 20%, transparent);
max-width: min(46rem, 100%);
}
.tool-panel-mode-prompt__options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
.tool-panel-mode-prompt__card {
display: flex;
flex-direction: column;
gap: 1rem;
background: linear-gradient(145deg,
color-mix(in srgb, var(--bg-surface) 96%, transparent),
color-mix(in srgb, var(--bg-muted) 70%, transparent)
);
width: 100%;
max-width: 19rem;
}
.tool-panel-mode-prompt__card--sidebar {
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 18%, var(--border-subtle));
background: linear-gradient(165deg,
color-mix(in srgb, var(--bg-surface) 96%, transparent),
color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 8%, transparent)
);
}
.tool-panel-mode-prompt__preview {
border-radius: 0.9rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--bg-muted) 82%, transparent), transparent 75%);
padding: 0.75rem;
width: 100%;
display: flex;
gap: 0.65rem;
min-height: 6.5rem;
margin-top: auto;
}
.tool-panel-mode-prompt__preview--sidebar {
align-items: stretch;
}
.tool-panel-mode-prompt__sidebar-panel {
width: 3rem;
border-radius: 0.65rem;
padding: 0.45rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
background: linear-gradient(180deg,
color-mix(in srgb, var(--bg-muted) 88%, transparent),
color-mix(in srgb, var(--bg-muted) 72%, transparent)
);
border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent);
}
.tool-panel-mode-prompt__sidebar-search {
height: 0.5rem;
border-radius: 0.4rem;
background: color-mix(in srgb, var(--bg-background) 90%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, transparent);
}
.tool-panel-mode-prompt__sidebar-item {
height: 0.55rem;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 32%, var(--bg-muted));
}
.tool-panel-mode-prompt__sidebar-item--muted {
background: color-mix(in srgb, var(--bg-background) 88%, transparent);
}
.tool-panel-mode-prompt__workspace {
flex: 1;
border-radius: 0.65rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent);
padding: 0.5rem;
display: grid;
gap: 0.35rem;
grid-template-rows: 1.4fr 0.6fr;
background: linear-gradient(160deg,
color-mix(in srgb, var(--bg-background) 94%, transparent),
color-mix(in srgb, var(--bg-muted) 68%, transparent)
);
}
.tool-panel-mode-prompt__workspace-page {
border-radius: 0.45rem;
background: color-mix(in srgb, var(--bg-surface) 96%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bg-background) 60%, transparent);
}
.tool-panel-mode-prompt__workspace-page--secondary {
opacity: 0.55;
}
.tool-panel-mode-prompt__preview--legacy {
align-items: flex-start;
justify-content: center;
padding: 0.65rem 0.6rem;
}
.tool-panel-mode-prompt__legacy-columns {
width: 100%;
display: flex;
gap: 0.45rem;
justify-content: space-between;
}
.tool-panel-mode-prompt__legacy-column {
flex: 1;
display: grid;
gap: 0.3rem;
}
.tool-panel-mode-prompt__legacy-card {
border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent);
background: linear-gradient(150deg,
color-mix(in srgb, var(--bg-muted) 88%, transparent),
color-mix(in srgb, var(--bg-background) 76%, transparent)
);
height: 1.2rem;
}
.tool-panel-mode-prompt__legacy-card--muted {
opacity: 0.55;
}
.tool-panel-mode-prompt__preview--fullscreen {
align-items: flex-start;
justify-content: center;
padding: 0.65rem 0.6rem;
}
.tool-panel-mode-prompt__fullscreen-columns {
width: 100%;
display: flex;
gap: 0.45rem;
justify-content: space-between;
}
.tool-panel-mode-prompt__fullscreen-column {
flex: 1;
display: grid;
gap: 0.3rem;
}
.tool-panel-mode-prompt__fullscreen-card {
border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent);
background: linear-gradient(150deg,
color-mix(in srgb, var(--bg-muted) 88%, transparent),
color-mix(in srgb, var(--bg-background) 76%, transparent)
);
height: 1.2rem;
}
.tool-panel-mode-prompt__fullscreen-card--muted {
opacity: 0.55;
}
.tool-panel-mode-prompt__card-content {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.tool-panel-mode-prompt__action {
box-shadow: none;
}
.tool-panel-mode-prompt__action:hover {
box-shadow: 0 10px 18px color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 25%, transparent);
}
.tool-panel-mode-prompt__maybe-later {
color: color-mix(in srgb, var(--text-secondary) 90%, var(--text-muted));
}
.tool-panel-mode-prompt__maybe-later:hover {
background: color-mix(in srgb, var(--bg-muted) 78%, transparent);
}
@media (max-width: 600px) {
.tool-panel-mode-prompt__options {
grid-template-columns: 1fr;
justify-items: stretch;
}
}
@@ -0,0 +1,145 @@
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 './ToolPanelModePrompt.css';
import { useToolPanelModePreference } from '../../hooks/useToolPanelModePreference';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
// type moved to hook
const ToolPanelModePrompt = () => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const [opened, setOpened] = useState(false);
const { hydrated, shouldShowPrompt, markPromptSeen, setPreferredMode } = useToolPanelModePreference();
useEffect(() => {
if (shouldShowPrompt) {
setOpened(true);
}
}, [shouldShowPrompt]);
const handleSelect = (mode: ToolPanelMode) => {
setToolPanelMode(mode);
setPreferredMode(mode);
markPromptSeen();
setOpened(false);
};
const handleDismiss = () => {
markPromptSeen();
setOpened(false);
};
if (!hydrated) {
return null;
}
return (
<Modal
opened={opened}
onClose={handleDismiss}
centered
size="xl"
radius="lg"
overlayProps={{ blur: 6, opacity: 0.35 }}
classNames={{ content: 'tool-panel-mode-prompt__modal' }}
title={t('toolPanel.modePrompt.title', 'Choose how you browse tools')}
>
<Stack gap="lg">
<Text size="sm" c="dimmed">
{t('toolPanel.modePrompt.description', 'Preview both layouts and decide how you want to explore Stirling PDF tools.')}
</Text>
<div className="tool-panel-mode-prompt__options">
<Card withBorder radius="lg" shadow="sm" padding="lg" className="tool-panel-mode-prompt__card tool-panel-mode-prompt__card--sidebar">
<Stack gap="md" className="tool-panel-mode-prompt__card-content">
<Group justify="space-between">
<Stack gap={2}>
<Text fw={600}>{t('toolPanel.modePrompt.sidebarTitle', 'Sidebar mode')}</Text>
<Text size="sm" c="dimmed">
{t('toolPanel.modePrompt.sidebarDescription', 'Keep tools alongside your workspace for quick switching.')}
</Text>
</Stack>
<Badge color="blue" variant="filled">
{t('toolPanel.modePrompt.recommended', 'Recommended')}
</Badge>
</Group>
<div className="tool-panel-mode-prompt__preview tool-panel-mode-prompt__preview--sidebar" aria-hidden>
<div className="tool-panel-mode-prompt__sidebar-panel">
<span className="tool-panel-mode-prompt__sidebar-search" />
<span className="tool-panel-mode-prompt__sidebar-item" />
<span className="tool-panel-mode-prompt__sidebar-item" />
<span className="tool-panel-mode-prompt__sidebar-item" />
<span className="tool-panel-mode-prompt__sidebar-item tool-panel-mode-prompt__sidebar-item--muted" />
</div>
<div className="tool-panel-mode-prompt__workspace" aria-hidden>
<div className="tool-panel-mode-prompt__workspace-page" />
<div className="tool-panel-mode-prompt__workspace-page tool-panel-mode-prompt__workspace-page--secondary" />
</div>
</div>
<Button
variant={toolPanelMode === 'sidebar' ? 'filled' : 'light'}
color="blue"
radius="md"
className="tool-panel-mode-prompt__action"
onClick={() => handleSelect('sidebar')}
>
{t('toolPanel.modePrompt.chooseSidebar', 'Use sidebar mode')}
</Button>
</Stack>
</Card>
<Card withBorder radius="lg" shadow="xs" padding="lg" className="tool-panel-mode-prompt__card">
<Stack gap="md" className="tool-panel-mode-prompt__card-content">
<Stack gap={2}>
<Text fw={600}>{t('toolPanel.modePrompt.fullscreenTitle', 'Fullscreen mode')}</Text>
<Text size="sm" c="dimmed">
{t('toolPanel.modePrompt.fullscreenDescription', 'Browse every tool in a catalogue that covers the workspace until you pick one.')}
</Text>
</Stack>
<div className="tool-panel-mode-prompt__preview tool-panel-mode-prompt__preview--fullscreen" aria-hidden>
<div className="tool-panel-mode-prompt__fullscreen-columns">
<div className="tool-panel-mode-prompt__fullscreen-column">
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card tool-panel-mode-prompt__fullscreen-card--muted" />
</div>
<div className="tool-panel-mode-prompt__fullscreen-column">
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card tool-panel-mode-prompt__fullscreen-card--muted" />
</div>
<div className="tool-panel-mode-prompt__fullscreen-column">
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card tool-panel-mode-prompt__fullscreen-card--muted" />
</div>
</div>
</div>
<Button
variant={toolPanelMode === 'fullscreen' ? 'filled' : 'outline'}
color="blue"
radius="md"
className="tool-panel-mode-prompt__action"
onClick={() => handleSelect('fullscreen')}
>
{t('toolPanel.modePrompt.chooseFullscreen', 'Use fullscreen mode')}
</Button>
</Stack>
</Card>
</div>
<Button
variant="subtle"
color="gray"
radius="md"
className="tool-panel-mode-prompt__maybe-later"
onClick={handleDismiss}
>
{t('toolPanel.modePrompt.dismiss', 'Maybe later')}
</Button>
</Stack>
</Modal>
);
};
export default ToolPanelModePrompt;
+64 -31
View File
@@ -4,9 +4,15 @@ import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
import "./toolPicker/ToolPicker.css";
import { useToolSections } from "../../hooks/useToolSections";
import type { SubcategoryGroup } from "../../hooks/useToolSections";
import { useFavoriteToolItems } from "../../hooks/tools/useFavoriteToolItems";
import NoToolsFound from "./shared/NoToolsFound";
import { renderToolButtons } from "./shared/renderToolButtons";
import { ToolId } from "src/types/toolId";
import Badge from "../shared/Badge";
import SubcategoryHeader from "./shared/SubcategoryHeader";
import ToolButton from "./toolPicker/ToolButton";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { ToolId } from "../../types/toolId";
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -62,11 +68,23 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
}, []);
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const quickSection = useMemo(
() => visibleSections.find(s => s.key === 'quick'),
[visibleSections]
);
const recommendedItems = useMemo(() => {
if (!quickSection) return [] as Array<{ id: string; tool: ToolRegistryEntry }>;
const items: Array<{ id: string; tool: ToolRegistryEntry }> = [];
quickSection.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((toolEntry) => items.push(toolEntry)));
return items;
}, [quickSection]);
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
const allSection = useMemo(
() => visibleSections.find(s => s.key === 'all'),
[visibleSections]
@@ -88,7 +106,9 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
};
// Build flat list by subcategory for search mode
const { searchGroups } = useToolSections(isSearching ? filteredTools : []);
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
return (
<Box
@@ -116,7 +136,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
{searchGroups.length === 0 ? (
<NoToolsFound />
) : (
searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect))
searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect, true, false, filteredTools, true))
)}
</Stack>
) : (
@@ -143,24 +163,46 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
onClick={() => scrollTo(quickAccessRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: ".5rem",
padding: "0.125rem 0.5rem",
fontSize: ".75rem",
fontWeight: 700
}}
>
{quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
<Badge>
{recommendedCount}
</Badge>
</div>
<Box ref={quickAccessRef} w="100%" my="sm">
<Stack p="sm" gap="xs">
{quickSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, false)
{favoriteToolItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
</Stack>
</Box>
@@ -189,25 +231,16 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
onClick={() => scrollTo(allToolsRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: ".5rem",
padding: "0.125rem 0.5rem",
fontSize: ".75rem",
fontWeight: 700
}}
>
<Badge>
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
</Badge>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true)
)}
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
)}
</Stack>
</Box>
</>
@@ -1,8 +1,8 @@
import { Suspense } from "react";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { BaseToolProps } from "../../types/tool";
import { ToolId } from "../../types/toolId";
import ToolLoadingFallback from "./ToolLoadingFallback";
import { ToolId } from "src/types/toolId";
interface ToolRendererProps extends BaseToolProps {
selectedToolKey: ToolId;
@@ -17,7 +17,14 @@ const ToolRenderer = ({
}: ToolRendererProps) => {
// Get the tool from context (instead of direct hook call)
const { toolRegistry } = useToolWorkflow();
const selectedTool = toolRegistry[selectedToolKey];
const selectedTool = (selectedToolKey in toolRegistry)
? toolRegistry[selectedToolKey as ToolId]
: undefined;
// Handle tools that only work in workbenches (read, multiTool)
if (selectedTool && !selectedTool.component && selectedTool.workbench) {
return null; // These tools render in their workbench, not in the sidebar
}
if (!selectedTool || !selectedTool.component) {
return <div>Tool not found: {selectedToolKey}</div>;
@@ -10,6 +10,7 @@ import {
Divider,
Modal
} from '@mantine/core';
import { Z_INDEX_AUTOMATE_MODAL } from '../../../styles/zIndex';
import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal';
@@ -221,6 +222,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
onClose={handleCancelBack}
title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')}
centered
zIndex={Z_INDEX_AUTOMATE_MODAL}
>
<Stack gap="md">
<Text>
@@ -8,6 +8,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip';
import { ToolIcon } from '../../shared/ToolIcon';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from 'src/types/toolId';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
@@ -31,7 +32,7 @@ interface AutomationEntryProps {
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<string, ToolRegistryEntry>;
toolRegistry?: Record<ToolId, ToolRegistryEntry>;
}
export default function AutomationEntry({
@@ -55,8 +56,8 @@ export default function AutomationEntry({
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation]?.name) {
return toolRegistry[operation].name;
if (toolRegistry?.[operation as ToolId]?.name) {
return toolRegistry[operation as ToolId].name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
@@ -30,7 +30,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
React.useEffect(() => {
useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
@@ -47,7 +47,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
}, [automation, toolRegistry]);
// Cleanup when component unmounts
React.useEffect(() => {
useEffect(() => {
return () => {
// Reset progress state when component unmounts
setExecutionSteps([]);
@@ -7,6 +7,7 @@ import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSugges
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
import { iconMap } from './iconMap';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
@@ -15,7 +16,7 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
}
export default function AutomationSelection({
@@ -9,6 +9,7 @@ import {
Text,
Alert
} from '@mantine/core';
import { Z_INDEX_AUTOMATE_MODAL } from '../../../styles/zIndex';
import SettingsIcon from '@mui/icons-material/Settings';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
@@ -103,6 +104,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
}
size="lg"
centered
zIndex={Z_INDEX_AUTOMATE_MODAL}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
@@ -6,12 +6,13 @@ import CloseIcon from "@mui/icons-material/Close";
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import { AutomationTool } from "../../../types/automation";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { ToolId } from "../../../types/toolId";
import ToolSelector from "./ToolSelector";
import AutomationEntry from "./AutomationEntry";
interface ToolListProps {
tools: AutomationTool[];
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
onToolRemove: (index: number) => void;
onToolConfigure: (index: number) => void;
@@ -34,7 +35,7 @@ export default function ToolList({
const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation);
const toolEntry = toolRegistry[newOperation];
const toolEntry = toolRegistry[newOperation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
@@ -6,12 +6,12 @@ import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
import ToolButton from '../toolPicker/ToolButton';
import { ToolId } from 'src/types/toolId';
import { ToolId } from '../../../types/toolId';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
excludeTools?: string[];
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
toolRegistry: Record<ToolId, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
selectedValue?: string; // For showing current selection when editing existing tool
placeholder?: string; // Custom placeholder text
}
@@ -54,16 +54,16 @@ export default function ToolSelector({
// Create filtered tool registry for ToolSearch
const filteredToolRegistry = useMemo(() => {
const registry: Record<string, ToolRegistryEntry> = {};
const registry: Record<ToolId, ToolRegistryEntry> = {} as Record<ToolId, ToolRegistryEntry>;
baseFilteredTools.forEach(([key, tool]) => {
registry[key] = tool;
registry[key as ToolId] = tool;
});
return registry;
}, [baseFilteredTools]);
// Transform filteredTools to the expected format for useToolSections
const transformedFilteredTools = useMemo(() => {
return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
return filteredTools.map(([id, tool]) => ({ item: [id as ToolId, tool] as [ToolId, ToolRegistryEntry] }));
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
@@ -89,7 +89,7 @@ export default function ToolSelector({
}
// Find the "all" section which contains all tools without duplicates
const allSection = sections.find(s => (s as any).key === 'all');
const allSection = sections.find(s => s.key === 'all');
return allSection?.subcategories || [];
}, [isSearching, searchGroups, sections, baseFilteredTools]);
@@ -101,7 +101,7 @@ export default function ToolSelector({
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching, true)
renderToolButtons(t, subcategory as any, null, handleToolSelect, !isSearching, true)
), [displayGroups, handleToolSelect, isSearching, t]
);
@@ -143,8 +143,8 @@ export default function ToolSelector({
// Get display value for selected tool
const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue]) {
return toolRegistry[selectedValue].name;
if (selectedValue && toolRegistry[selectedValue as ToolId]) {
return toolRegistry[selectedValue as ToolId].name;
}
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
};
@@ -153,11 +153,11 @@ export default function ToolSelector({
<div ref={containerRef} className='rounded-xl'>
{/* Always show the target - either selected tool or search input */}
{selectedValue && toolRegistry[selectedValue] && !opened ? (
{selectedValue && toolRegistry[selectedValue as ToolId] && !opened ? (
// Show selected tool in AutomationEntry style when tool is selected and dropdown closed
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
borderRadius: "var(--mantine-radius-lg)" }}>
<ToolButton id={'tool' as any /* FIX ME */} tool={toolRegistry[selectedValue]} isSelected={false}
<ToolButton id={'tool' as ToolId} tool={toolRegistry[selectedValue as ToolId]} isSelected={false}
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
</div>
) : (
@@ -0,0 +1,108 @@
import React from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../shared/Tooltip';
import HotkeyDisplay from '../../hotkeys/HotkeyDisplay';
import FavoriteStar from '../toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared';
interface CompactToolItemProps {
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onClick: () => void;
tooltipPortalTarget?: HTMLElement | undefined;
}
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, false);
const iconClasses = 'tool-panel__fullscreen-list-icon';
let iconNode: React.ReactNode = null;
if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) {
const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>;
iconNode = React.cloneElement(element, {
style: {
...(element.props.style || {}),
fontSize: '1.5rem',
},
});
} else {
iconNode = tool.icon;
}
const compactButton = (
<button
type="button"
className={`tool-panel__fullscreen-list-item ${getItemClasses(false)} ${isSelected ? 'tool-panel__fullscreen-list-item--selected' : ''} ${!disabled ? 'tool-panel__fullscreen-list-item--with-star' : ''}`}
onClick={onClick}
aria-disabled={disabled}
disabled={disabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-list-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
</span>
{!disabled && (
<div className="tool-panel__fullscreen-star-compact">
<FavoriteStar
isFavorite={isFav}
onToggle={toggleFavorite}
size="xs"
/>
</div>
)}
</button>
);
const tooltipContent = disabled
? (
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
{binding && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} />
</div>
)}
</div>
);
return (
<Tooltip
content={tooltipContent}
position="top"
portalTarget={tooltipPortalTarget}
arrow
delay={80}
>
{compactButton}
</Tooltip>
);
};
export default CompactToolItem;
@@ -0,0 +1,88 @@
import React from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import HotkeyDisplay from '../../hotkeys/HotkeyDisplay';
import FavoriteStar from '../toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared';
interface DetailedToolItemProps {
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onClick: () => void;
}
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, true);
const iconClasses = 'tool-panel__fullscreen-icon';
let iconNode: React.ReactNode = null;
if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) {
const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>;
iconNode = React.cloneElement(element, {
style: {
...(element.props.style || {}),
fontSize: '1.75rem',
},
});
} else {
iconNode = tool.icon;
}
return (
<button
type="button"
className={`tool-panel__fullscreen-item ${getItemClasses(true)} ${isSelected ? 'tool-panel__fullscreen-item--selected' : ''} tool-panel__fullscreen-item--with-star`}
onClick={onClick}
aria-disabled={disabled}
disabled={disabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
{tool.description}
</Text>
{binding && (
<div className="tool-panel__fullscreen-shortcut">
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} size="sm" />
</div>
)}
</span>
{!disabled && (
<div className="tool-panel__fullscreen-star">
<FavoriteStar
isFavorite={isFav}
onToggle={toggleFavorite}
size="sm"
/>
</div>
)}
</button>
);
};
export default DetailedToolItem;
@@ -0,0 +1,45 @@
import { useHotkeys } from '../../../contexts/HotkeyContext';
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
export const getItemClasses = (isDetailed: boolean): string => {
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
};
export const getIconBackground = (categoryColor: string, isDetailed: boolean): string => {
const baseColor = isDetailed ? 'var(--fullscreen-bg-icon-detailed)' : 'var(--fullscreen-bg-icon-compact)';
const blend1 = isDetailed ? '18%' : '15%';
const blend2 = isDetailed ? '8%' : '6%';
return `linear-gradient(135deg,
color-mix(in srgb, ${categoryColor} ${blend1}, ${baseColor}),
color-mix(in srgb, ${categoryColor} ${blend2}, ${baseColor})
)`;
};
export const getIconStyle = (): Record<string, string> => {
return {};
};
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
};
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
const { hotkeys } = useHotkeys();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const isFav = isFavorite(id as ToolId);
const binding = hotkeys[id as ToolId];
const disabled = isToolDisabled(id, tool);
return {
binding,
isFav,
toggleFavorite: () => toggleFavorite(id as ToolId),
disabled,
};
}
@@ -0,0 +1,50 @@
.fileListContainer {
max-height: 16.25rem;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
}
.fileItem {
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-sm);
align-items: center;
width: 100%;
}
.fileNameContainer {
flex: 1;
min-width: 0;
}
.fileName {
font-size: var(--mantine-font-size-sm);
font-weight: 400;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
word-break: break-word;
}
.fileSize {
flex-shrink: 0;
}
.removeButton {
flex-shrink: 0;
}
.countLabel {
width: 140px;
flex-shrink: 0;
}
.fileGroup {
flex: 1;
min-width: 0;
align-items: center;
}
@@ -0,0 +1,178 @@
import { Stack, Text, Group, Select, SegmentedControl, NumberInput, Button, ActionIcon, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { type OverlayPdfsParameters, type OverlayMode } from '../../../hooks/tools/overlayPdfs/useOverlayPdfsParameters';
import LocalIcon from '../../shared/LocalIcon';
import { useFilesModalContext } from '../../../contexts/FilesModalContext';
import styles from './OverlayPdfsSettings.module.css';
interface OverlayPdfsSettingsProps {
parameters: OverlayPdfsParameters;
onParameterChange: <K extends keyof OverlayPdfsParameters>(key: K, value: OverlayPdfsParameters[K]) => void;
disabled?: boolean;
}
export default function OverlayPdfsSettings({ parameters, onParameterChange, disabled = false }: OverlayPdfsSettingsProps) {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const handleOverlayFilesChange = (files: File[]) => {
onParameterChange('overlayFiles', files);
// Reset counts to match number of files if in FixedRepeatOverlay
if (parameters.overlayMode === 'FixedRepeatOverlay') {
const nextCounts = files.map((_, i) => parameters.counts[i] && parameters.counts[i] > 0 ? parameters.counts[i] : 1);
onParameterChange('counts', nextCounts);
}
};
const handleModeChange = (mode: OverlayMode) => {
onParameterChange('overlayMode', mode);
if (mode !== 'FixedRepeatOverlay') {
onParameterChange('counts', []);
} else if (parameters.overlayFiles?.length > 0) {
onParameterChange('counts', parameters.overlayFiles.map((_, i) => parameters.counts[i] && parameters.counts[i] > 0 ? parameters.counts[i] : 1));
}
};
const handleOpenOverlayFilesModal = () => {
if (disabled) return;
openFilesModal({
customHandler: (files: File[]) => {
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
}
});
};
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.mode.label', 'Overlay Mode')}</Text>
<Select
data={[
{ value: 'SequentialOverlay', label: t('overlay-pdfs.mode.sequential', 'Sequential Overlay') },
{ value: 'InterleavedOverlay', label: t('overlay-pdfs.mode.interleaved', 'Interleaved Overlay') },
{ value: 'FixedRepeatOverlay', label: t('overlay-pdfs.mode.fixedRepeat', 'Fixed Repeat Overlay') },
]}
value={parameters.overlayMode}
onChange={(v) => handleModeChange((v || 'SequentialOverlay') as OverlayMode)}
disabled={disabled}
/>
</Stack>
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.position.label', 'Overlay Position')}</Text>
<SegmentedControl
value={String(parameters.overlayPosition)}
onChange={(v) => onParameterChange('overlayPosition', (v === '1' ? 1 : 0) as 0 | 1)}
data={[
{ label: t('overlay-pdfs.position.foreground', 'Foreground'), value: '0' },
{ label: t('overlay-pdfs.position.background', 'Background'), value: '1' },
]}
disabled={disabled}
/>
</Stack>
{parameters.overlayMode === 'FixedRepeatOverlay' && (
<>
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.counts.label', 'Overlay Counts')}</Text>
{parameters.overlayFiles?.length > 0 ? (
<Stack gap="xs">
{parameters.overlayFiles.map((_, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<Text size="sm" className={styles.countLabel}>
{t('overlay-pdfs.counts.item', 'Count for file')} {index + 1}
</Text>
<NumberInput
min={1}
step={1}
value={parameters.counts[index] ?? 1}
onChange={(value) => {
const next = [...(parameters.counts || [])];
next[index] = Number(value) || 1;
onParameterChange('counts', next);
}}
disabled={disabled}
/>
</Group>
))}
</Stack>
) : (
<Text size="sm" c="dimmed">
{t('overlay-pdfs.counts.noFiles', 'Add overlay files to configure counts')}
</Text>
)}
</Stack>
</>
)}
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.overlayFiles.label', 'Overlay Files')}</Text>
<Button
size="xs"
color="blue"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{parameters.overlayFiles?.length > 0
? t('overlay-pdfs.overlayFiles.addMore', 'Add more PDFs...')
: t('overlay-pdfs.overlayFiles.placeholder', 'Choose PDF(s)...')}
</Button>
{parameters.overlayFiles?.length > 0 && (() => {
return (
<div className={styles.fileListContainer}>
<Stack gap="xs">
{parameters.overlayFiles.map((file, index) => (
<Group
key={index}
justify="space-between"
p="xs"
className={styles.fileItem}
>
<Group gap="xs" className={styles.fileGroup}>
<div className={styles.fileNameContainer}>
<div
className={styles.fileName}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" className={styles.fileSize}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
className={styles.removeButton}
onClick={() => {
const next = (parameters.overlayFiles || []).filter((_, i) => i !== index);
handleOverlayFilesChange(next);
}}
disabled={disabled}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</div>
);
})()}
</Stack>
</Stack>
);
}
@@ -15,7 +15,8 @@ export const renderToolButtons = (
onSelect: (id: ToolId) => void,
showSubcategoryHeader: boolean = true,
disableNavigation: boolean = false,
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
searchResults?: Array<{ item: [string, any]; matchedText?: string }>,
hasStars: boolean = false
) => {
// Create a map of matched text for quick lookup
const matchedTextMap = new Map<string, string>();
@@ -43,6 +44,7 @@ export const renderToolButtons = (
onSelect={onSelect}
disableNavigation={disableNavigation}
matchedSynonym={matchedSynonym}
hasStars={hasStars}
/>
);
})}
@@ -0,0 +1,48 @@
import React from "react";
import { ActionIcon } from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import StarBorderRoundedIcon from '@mui/icons-material/StarBorderRounded';
interface FavoriteStarProps {
isFavorite: boolean;
onToggle: () => void;
className?: string;
size?: MantineSize;
}
const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, className, size = "xs" }) => {
const { t } = useTranslation();
return (
<ActionIcon
component="span"
variant="subtle"
radius="xl"
size={size}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onToggle();
}}
onMouseDown={(e: React.MouseEvent) => {
e.stopPropagation();
}}
onKeyDown={(e: React.KeyboardEvent) => {
e.stopPropagation();
}}
className={className}
aria-label={isFavorite ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{isFavorite ? (
<StarRoundedIcon fontSize="inherit" style={{ color: 'var(--special-color-favorites)', fontSize: '1rem' }} />
) : (
<StarBorderRoundedIcon fontSize="inherit" style={{ fontSize: '1rem' }} />
)}
</ActionIcon>
);
};
export default FavoriteStar;
@@ -9,7 +9,9 @@ import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
import { useHotkeys } from "../../../contexts/HotkeyContext";
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
import { ToolId } from "src/types/toolId";
import FavoriteStar from "./FavoriteStar";
import { useToolWorkflow } from "../../../contexts/ToolWorkflowContext";
import { ToolId } from "../../../types/toolId";
interface ToolButtonProps {
id: ToolId;
@@ -19,15 +21,18 @@ interface ToolButtonProps {
rounded?: boolean;
disableNavigation?: boolean;
matchedSynonym?: string;
hasStars?: boolean;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
const { t } = useTranslation();
// Special case: read and multiTool are navigational tools that are always available
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const { hotkeys } = useHotkeys();
const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const fav = isFavorite(id as ToolId);
const handleClick = (id: ToolId) => {
if (isUnavailable) return;
@@ -107,8 +112,12 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
@@ -128,8 +137,12 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
@@ -146,16 +159,36 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' }, label: { overflow: 'visible' } }}
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
cursor: isUnavailable ? 'not-allowed' : undefined,
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
{buttonContent}
</Button>
);
const star = hasStars && !isUnavailable ? (
<FavoriteStar
isFavorite={fav}
onToggle={() => toggleFavorite(id as ToolId)}
className="tool-button-star"
size="xs"
/>
) : null;
return (
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
{buttonElement}
</Tooltip>
<div className="tool-button-container">
{star}
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
{buttonElement}
</Tooltip>
</div>
);
};
@@ -71,6 +71,24 @@
line-height: 1;
}
/* Container to enable hover-only favourite star overlay */
.tool-button-container {
position: relative;
}
.tool-button-star {
position: absolute;
top: 0.35rem;
right: 0.35rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: var(--z-toolpicker-star); /* lower than sticky section headers */
}
.tool-button-container:hover .tool-button-star {
opacity: 1;
}
.search-input-container {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -6,12 +6,13 @@ import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { TextInput } from "../../shared/TextInput";
import "./ToolPicker.css";
import { rankByFuzzy, idToWords } from "../../../utils/fuzzySearch";
import { ToolId } from "src/types/toolId";
interface ToolSearchProps {
value: string;
onChange: (value: string) => void;
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
onToolSelect?: (toolId: string) => void;
toolRegistry: Partial<Record<ToolId, ToolRegistryEntry>>;
onToolSelect?: (toolId: ToolId) => void;
mode: "filter" | "dropdown" | "unstyled";
selectedToolKey?: string | null;
placeholder?: string;
@@ -81,15 +82,15 @@ const ToolSearch = ({
}, [autoFocus]);
const searchInput = (
<TextInput
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off"
onFocus={onFocus}
/>
<TextInput
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off"
onFocus={onFocus}
/>
);
if (mode === "filter") {
@@ -126,7 +127,7 @@ const ToolSearch = ({
key={id}
variant="subtle"
onClick={() => {
onToolSelect?.(id);
onToolSelect?.(id as ToolId);
setDropdownOpen(false);
}}
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
@@ -0,0 +1,145 @@
import React, { useMemo } from 'react';
import { Badge, Group, Stack, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { SignatureValidationReportData } from '../../../types/validateSignature';
import './reportView/styles.css';
import ThumbnailPreview from './reportView/ThumbnailPreview';
import FileSummaryHeader from './reportView/FileSummaryHeader';
import SignatureSection from './reportView/SignatureSection';
interface ValidateSignatureReportViewProps {
data: SignatureValidationReportData;
}
const NoSignatureSection = ({ message, label }: { message: string; label: string }) => (
<Stack align="center" justify="center" gap="xs" style={{ minHeight: 360, width: '100%' }}>
<Badge color="gray" variant="light" size="lg" style={{ textTransform: 'uppercase' }}>
{label}
</Badge>
<Text size="sm" c="dimmed" style={{ textAlign: 'center' }}>
{message}
</Text>
</Stack>
);
const ValidateSignatureReportView: React.FC<ValidateSignatureReportViewProps> = ({ data }) => {
const { t } = useTranslation();
const noSignaturesLabel = t('validateSignature.noSignaturesShort', 'No signatures');
const pages = useMemo(() => {
const result: Array<{
entry: SignatureValidationReportData['entries'][number];
signatureIndex: number | null;
includeSummary: boolean;
}> = [];
for (const entry of data.entries) {
if (entry.signatures.length === 0 || entry.error) {
result.push({ entry, signatureIndex: null, includeSummary: true });
continue;
}
// First page includes summary and the first signature
result.push({ entry, signatureIndex: 0, includeSummary: true });
// Subsequent signatures each get their own page
for (let i = 1; i < entry.signatures.length; i += 1) {
result.push({ entry, signatureIndex: i, includeSummary: false });
}
}
return result;
}, [data.entries]);
return (
<div className="report-container">
<Stack gap="xl" align="center">
<Stack gap="xs" align="center">
<Badge size="lg" color="blue" variant="light">
{t('validateSignature.report.title', 'Signature Validation Report')}
</Badge>
<Text size="sm" c="dimmed">
{t('validateSignature.report.generatedAt', 'Generated')}{' '}
{new Date(data.generatedAt).toLocaleString()}
</Text>
</Stack>
{pages.map((pageDef, index) => (
<div className="simulated-page" key={`${pageDef.entry.fileId}-${index}`}>
<Stack gap="lg" style={{ flex: 1 }}>
{pageDef.includeSummary && (
<>
<Group align="flex-start" gap="lg">
<ThumbnailPreview
thumbnailUrl={pageDef.entry.thumbnailUrl}
fileName={pageDef.entry.fileName}
/>
<Stack gap="sm" style={{ flex: 1 }}>
<Group justify="space-between" align="flex-start">
<div>
<Text fw={700} size="xl" style={{ lineHeight: 1.1 }}>
{pageDef.entry.fileName}
</Text>
<Text size="sm" c="dimmed">
{t('validateSignature.report.entryLabel', 'Signature Summary')}
</Text>
</div>
<Badge color="gray" variant="light">
{t('validateSignature.report.page', 'Page')} {index + 1}
</Badge>
</Group>
<FileSummaryHeader
fileSize={pageDef.entry.fileSize}
createdAt={pageDef.entry.createdAtLabel ?? null}
totalSignatures={pageDef.entry.signatures.length}
lastSignatureDate={pageDef.entry.signatures[0]?.signatureDate}
/>
</Stack>
</Group>
<Divider />
</>
)}
{pageDef.entry.error ? (
<NoSignatureSection
message={pageDef.entry.error}
label={t('validateSignature.status.invalid', 'Invalid')}
/>
) : pageDef.entry.signatures.length === 0 ? (
<NoSignatureSection
message={t(
'validateSignature.noSignatures',
'No digital signatures found in this document'
)}
label={noSignaturesLabel}
/>
) : (
<Stack gap="xl">
{pageDef.signatureIndex === null ? null : (
<SignatureSection
signature={pageDef.entry.signatures[pageDef.signatureIndex]}
index={pageDef.signatureIndex}
/>
)}
</Stack>
)}
</Stack>
<Group justify="space-between" align="center" mt="auto" pt="md">
<Text size="xs" c="dimmed">
{t('validateSignature.report.footer', 'Validated via Stirling PDF')}
</Text>
<Text size="xs" c="dimmed">
{t('validateSignature.report.page', 'Page')} {index + 1} / {pages.length}
</Text>
</Group>
</div>
))}
</Stack>
</div>
);
};
export default ValidateSignatureReportView;
@@ -0,0 +1,230 @@
import { useCallback, useMemo, useState } from 'react';
import { Alert, Badge, Button, Divider, Group, Loader, Stack, Text, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { SignatureValidationReportEntry } from '../../../types/validateSignature';
import type { ValidateSignatureOperationHook } from '../../../hooks/tools/validateSignature/useValidateSignatureOperation';
import './reportView/styles.css';
interface ValidateSignatureResultsProps {
operation: ValidateSignatureOperationHook;
results: SignatureValidationReportEntry[];
isLoading: boolean;
errorMessage: string | null;
reportAvailable?: boolean;
}
const useFileSummary = (results: SignatureValidationReportEntry[]) => {
return useMemo(() => {
if (results.length === 0) {
return { fileCount: 0, signatureCount: 0, fullyValidCount: 0 };
}
let signatureCount = 0;
let fullyValidCount = 0;
results.forEach((result) => {
signatureCount += result.signatures.length;
result.signatures.forEach((signature) => {
const isFullyValid =
signature.valid &&
signature.chainValid &&
signature.trustValid &&
signature.notExpired &&
signature.notRevoked;
if (isFullyValid) {
fullyValidCount += 1;
}
});
});
return {
fileCount: results.length,
signatureCount,
fullyValidCount,
};
}, [results]);
};
const findFileByExtension = (files: File[], extension: string) => {
return files.find((file) => file.name.toLowerCase().endsWith(extension));
};
const ValidateSignatureResults = ({
operation,
results,
isLoading,
errorMessage,
}: ValidateSignatureResultsProps) => {
const { t } = useTranslation();
const summary = useFileSummary(results);
const pdfFile = useMemo(() => findFileByExtension(operation.files, '.pdf'), [operation.files]);
const csvFile = useMemo(() => findFileByExtension(operation.files, '.csv'), [operation.files]);
const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]);
const [selectedType, setSelectedType] = useState<'pdf' | 'csv' | 'json'>('pdf');
const selectedFile = useMemo(() => {
if (selectedType === 'pdf') return pdfFile ?? null;
if (selectedType === 'csv') return csvFile ?? null;
return jsonFile ?? null;
}, [selectedType, pdfFile, csvFile, jsonFile]);
const selectedDownloadLabel = useMemo(() => {
if (selectedType === 'pdf') return t('validateSignature.downloadPdf', 'Download PDF Report');
if (selectedType === 'csv') return t('validateSignature.downloadCsv', 'Download CSV');
return t('validateSignature.downloadJson', 'Download JSON');
}, [selectedType, t]);
const downloadTypeOptions = [
{ label: t('validateSignature.downloadType.pdf', 'PDF'), value: 'pdf' },
{ label: t('validateSignature.downloadType.csv', 'CSV'), value: 'csv' },
{ label: t('validateSignature.downloadType.json', 'JSON'), value: 'json' },
];
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}, []);
// Show the big loader only while we're still waiting for the first results.
if (isLoading && results.length === 0) {
return (
<Group justify="center" gap="sm" py="md">
<Loader size="sm" />
<Text>{t('validateSignature.processing', 'Validating signatures...')}</Text>
</Group>
);
}
if (!isLoading && results.length === 0) {
return (
<Alert color="gray" variant="light" title={t('validateSignature.results', 'Validation Results')}>
<Text size="sm">{t('validateSignature.noResults', 'Run the validation to generate a report.')}</Text>
</Alert>
);
}
return (
<Stack gap="md">
{/* While results are visible but background work continues (e.g. generating files),
show a light inline indicator without blocking downloads UI. */}
{isLoading && results.length > 0 && (
<Group justify="center" gap="xs">
<Loader size="xs" />
<Text size="sm">{t('validateSignature.finalizing', 'Preparing downloads...')}</Text>
</Group>
)}
{errorMessage && (
<Alert color="yellow" variant="light">
<Text size="sm">{errorMessage}</Text>
</Alert>
)}
<Group gap="sm">
<Badge color="blue" variant="light">
{t('validateSignature.report.filesEvaluated', '{{count}} files evaluated', {
count: summary.fileCount,
})}
</Badge>
<Badge color="teal" variant="light">
{t('validateSignature.report.signaturesFound', '{{count}} signatures detected', {
count: summary.signatureCount,
})}
</Badge>
{summary.signatureCount > 0 && (
<Badge color="green" variant="light">
{t('validateSignature.report.signaturesValid', '{{count}} fully valid', {
count: summary.fullyValidCount,
})}
</Badge>
)}
</Group>
<Stack gap="sm">
{results.map((result) => {
const hasError = Boolean(result.error);
const hasSignatures = result.signatures.length > 0;
const allValid = hasSignatures && result.signatures.every((signature) => signature.valid);
const badgeLabel = hasError
? t('validateSignature.status.invalid', 'Invalid')
: hasSignatures
? allValid
? t('validateSignature.status.valid', 'Valid')
: t('validateSignature.status.needsAttention', 'Needs Attention')
: t('validateSignature.noSignaturesShort', 'No signatures');
const badgeClass = hasError
? 'status-badge status-badge--invalid'
: hasSignatures
? allValid
? 'status-badge status-badge--valid'
: 'status-badge status-badge--warning'
: 'status-badge status-badge--neutral';
return (
<Stack key={result.fileId} gap={4} p="xs" style={{ borderLeft: '2px solid var(--mantine-color-gray-4)' }}>
<Group justify="space-between" align="center">
<Text fw={600} size="sm">
{result.fileName}
</Text>
<Badge className={badgeClass} variant="light">
{badgeLabel}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{t('validateSignature.report.signatureCountLabel', '{{count}} signatures', {
count: result.signatures.length,
})}
</Text>
{result.error && (
<Text size="xs" c="red">
{result.error}
</Text>
)}
{!result.error && result.signatures.length === 0 && (
<Text size="xs" c="dimmed">
{t('validateSignature.noSignatures', 'No digital signatures found in this document')}
</Text>
)}
</Stack>
);
})}
</Stack>
<Divider />
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('validateSignature.report.downloads', 'Downloads')}
</Text>
<SegmentedControl
value={selectedType}
onChange={(v) => setSelectedType(v as 'pdf' | 'csv' | 'json')}
data={downloadTypeOptions}
/>
<div>
<Button
color="blue"
onClick={() => selectedFile && handleDownload(selectedFile)}
disabled={!selectedFile}
>
{selectedDownloadLabel}
</Button>
</div>
{selectedType === 'pdf' && !pdfFile && (
<Text size="xs" c="dimmed">
{t('validateSignature.report.noPdf', 'PDF report will be available after a successful validation.')}
</Text>
)}
</Stack>
</Stack>
);
};
export default ValidateSignatureResults;
@@ -0,0 +1,67 @@
import { Card, Group, Stack, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import FileUploadButton from '../../shared/FileUploadButton';
import { ValidateSignatureParameters } from '../../../hooks/tools/validateSignature/useValidateSignatureParameters';
interface ValidateSignatureSettingsProps {
parameters: ValidateSignatureParameters;
onParameterChange: <K extends keyof ValidateSignatureParameters>(parameter: K, value: ValidateSignatureParameters[K]) => void;
disabled?: boolean;
}
const ValidateSignatureSettings = ({
parameters,
onParameterChange,
disabled = false,
}: ValidateSignatureSettingsProps) => {
const { t } = useTranslation();
const certFile = parameters.certFile;
const handleCertFileChange = (file: File | null) => {
onParameterChange('certFile', file);
};
return (
<Card withBorder radius="md" padding="md">
<Stack gap="sm">
<div>
<Text fw={600}>{t('validateSignature.selectCustomCert', 'Custom Certificate File X.509 (Optional)')}</Text>
<Text size="sm" c="dimmed">
{t(
'validateSignature.settings.certHint',
'Upload a trusted X.509 certificate to validate against a custom trust source.'
)}
</Text>
</div>
<Group align="center" gap="sm">
<FileUploadButton
file={certFile ?? undefined}
onChange={handleCertFileChange}
accept=".cer,.crt,.pem,.der"
disabled={disabled}
variant="filled"
/>
{certFile && (
<Button
variant="subtle"
color="gray"
onClick={() => handleCertFileChange(null)}
disabled={disabled}
>
{t('sign.clear', 'Clear')}
</Button>
)}
</Group>
{certFile && (
<Text size="xs" c="dimmed">
{t('size', 'Size')}: {Math.round(certFile.size / 1024)} KB
</Text>
)}
</Stack>
</Card>
);
};
export default ValidateSignatureSettings;
@@ -0,0 +1,23 @@
import React from 'react';
import { Text } from '@mantine/core';
import './styles.css';
const FieldBlock = (label: string, value: React.ReactNode) => {
const displayValue =
value === null || value === undefined || value === '' ? '-' : value;
return (
<div className="field-container" key={label}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: 0.6 }}>
{label}
</Text>
<div className="field-value">
<Text size="sm" fw={500} style={{ lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
{displayValue}
</Text>
</div>
</div>
);
};
export default FieldBlock;
@@ -0,0 +1,47 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import './styles.css';
import FieldBlock from './FieldBlock';
const formatDate = (value?: string | null) => {
if (!value) return '--';
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toLocaleString();
}
return value;
};
const formatFileSize = (bytes?: number | null) => {
if (bytes === undefined || bytes === null) return '--';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = bytes / Math.pow(1024, exponent);
return `${size.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
};
const FileSummaryHeader = ({
fileSize,
createdAt,
totalSignatures,
lastSignatureDate,
}: {
fileSize?: number | null;
createdAt?: string | null;
totalSignatures: number;
lastSignatureDate?: string | null;
}) => {
const { t } = useTranslation();
const infoBlocks = [
FieldBlock(t('files.size', 'File Size'), formatFileSize(fileSize ?? null)),
FieldBlock(t('files.created', 'Created'), createdAt || '-'),
FieldBlock(t('validateSignature.signatureDate', 'Signature Date'), formatDate(lastSignatureDate)),
FieldBlock(t('validateSignature.totalSignatures', 'Total Signatures'), totalSignatures.toString()),
];
return <div className="grid-container">{infoBlocks}</div>;
};
export default FileSummaryHeader;
@@ -0,0 +1,78 @@
import React from 'react';
import { Divider, Group, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
import SignatureStatusBadge from './SignatureStatusBadge';
import FieldBlock from './FieldBlock';
import './styles.css';
const formatDate = (value?: string | null) => {
if (!value) return '-';
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toLocaleString();
}
return value;
};
const SignatureSection = ({
signature,
index,
}: {
signature: SignatureValidationSignature;
index: number;
}) => {
const { t } = useTranslation();
const signatureFields = [
FieldBlock(t('validateSignature.signer', 'Signer'), signature.signerName || '-'),
FieldBlock(t('validateSignature.date', 'Date'), formatDate(signature.signatureDate)),
FieldBlock(t('validateSignature.reason', 'Reason'), signature.reason || '-'),
FieldBlock(t('validateSignature.location', 'Location'), signature.location || '-'),
];
const certificateFields = [
FieldBlock(t('validateSignature.cert.issuer', 'Issuer'), signature.issuerDN || '-'),
FieldBlock(t('validateSignature.cert.subject', 'Subject'), signature.subjectDN || '-'),
FieldBlock(t('validateSignature.cert.serialNumber', 'Serial Number'), signature.serialNumber || '-'),
FieldBlock(t('validateSignature.cert.validFrom', 'Valid From'), formatDate(signature.validFrom)),
FieldBlock(t('validateSignature.cert.validUntil', 'Valid Until'), formatDate(signature.validUntil)),
FieldBlock(t('validateSignature.cert.algorithm', 'Algorithm'), signature.signatureAlgorithm || '-'),
FieldBlock(
t('validateSignature.cert.keySize', 'Key Size'),
signature.keySize != null ? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}` : '--'
),
FieldBlock(t('validateSignature.cert.version', 'Version'), signature.version || '-'),
FieldBlock(
t('validateSignature.cert.keyUsage', 'Key Usage'),
signature.keyUsages.length > 0 ? signature.keyUsages.join(', ') : '--'
),
FieldBlock(t('validateSignature.cert.selfSigned', 'Self-Signed'), signature.selfSigned ? t('yes', 'Yes') : t('no', 'No')),
];
return (
<Stack gap="md" key={signature.id}>
<Group justify="space-between" align="center">
<Group gap="sm">
<Text fw={700} size="lg">
{t('validateSignature.signature._value', 'Signature')} {index + 1}
</Text>
<SignatureStatusBadge signature={signature} />
</Group>
{signature.errorMessage && (
<Text c="red" size="sm">{signature.errorMessage}</Text>
)}
</Group>
<div className="grid-container">{signatureFields}</div>
<Divider my="sm" />
<Text fw={600} size="sm" c="dimmed" tt="uppercase" style={{ letterSpacing: 0.8 }}>
{t('validateSignature.cert.details', 'Certificate Details')}
</Text>
<div className="grid-container">{certificateFields}</div>
</Stack>
);
};
export default SignatureSection;
@@ -0,0 +1,40 @@
import React from 'react';
import { Badge, Popover, Text } from '@mantine/core';
import './styles.css';
import { useTranslation } from 'react-i18next';
import { computeSignatureStatus } from '../../../../hooks/tools/validateSignature/utils/signatureStatus';
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
const SignatureStatusBadge = ({ signature }: { signature: SignatureValidationSignature }) => {
const { t } = useTranslation();
const status = computeSignatureStatus(signature, t);
const classMap = {
valid: 'status-badge status-badge--valid',
warning: 'status-badge status-badge--warning',
invalid: 'status-badge status-badge--invalid',
neutral: 'status-badge status-badge--neutral',
} as const;
return (
<Popover withinPortal position="bottom" withArrow shadow="md" disabled={status.details.length === 0}>
<Popover.Target>
<Badge className={classMap[status.kind]} variant="light" style={{ cursor: status.details.length ? 'pointer' : 'default' }}>
{status.label}
</Badge>
</Popover.Target>
{status.details.length > 0 && (
<Popover.Dropdown>
<Text size="sm" fw={600} mb={4}>{t('details', 'Details')}</Text>
{status.details.map((d, i) => (
<Text size="sm" key={i}>
- {d}
</Text>
))}
</Popover.Dropdown>
)}
</Popover>
);
};
export default SignatureStatusBadge;
@@ -0,0 +1,31 @@
import React from 'react';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import './styles.css';
const ThumbnailPreview = ({
thumbnailUrl,
fileName,
}: {
thumbnailUrl?: string | null;
fileName: string;
}) => {
if (thumbnailUrl) {
return (
<div className="thumbnail-container">
<img
src={thumbnailUrl}
alt={`${fileName} thumbnail`}
className="thumbnail-image"
/>
</div>
);
}
return (
<div className="thumbnail-placeholder">
<PictureAsPdfIcon fontSize="large" />
</div>
);
};
export default ThumbnailPreview;
@@ -0,0 +1,105 @@
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.field-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-value {
border: 1px solid rgb(var(--pdf-light-box-border));
border-radius: 8px;
padding: 0.65rem 0.75rem;
background-color: rgb(var(--pdf-light-box-bg));
min-height: 44px;
}
/* Status badge colors sourced from light palette tokens */
.status-badge {
border-radius: 9999px !important;
font-weight: 700 !important;
letter-spacing: 0.02em !important;
}
.status-badge--valid {
background-color: rgb(var(--pdf-light-status-valid-bg)) !important;
color: rgb(var(--pdf-light-status-valid-text)) !important;
}
.status-badge--warning {
background-color: rgb(var(--pdf-light-status-warning-bg)) !important;
color: rgb(var(--pdf-light-status-warning-text)) !important;
}
.status-badge--invalid {
background-color: rgb(var(--pdf-light-status-invalid-bg)) !important;
color: rgb(var(--pdf-light-status-invalid-text)) !important;
}
.status-badge--neutral {
background-color: rgb(var(--pdf-light-status-neutral-bg)) !important;
color: rgb(var(--pdf-light-status-neutral-text)) !important;
}
.simulated-page {
width: min(820px, 100%);
min-height: 1040px;
background-color: rgb(var(--pdf-light-simulated-page-bg)) !important;
box-shadow: 0 12px 32px rgba(var(--pdf-light-simulated-page-text), 0.12) !important;
border-radius: 12px !important;
padding: 48px 56px !important;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
color: rgb(var(--pdf-light-simulated-page-text)) !important;
}
/* Container for the interactive report view */
.report-container {
width: 100%;
height: 100%;
/* Match Active Files/Page Editor background */
background: var(--bg-background) !important;
padding: 32px 24px 48px;
overflow-y: auto;
}
/* Keep field blocks stable colors across themes */
.field-value {
border: 1px solid rgb(var(--pdf-light-box-border)) !important;
background-color: rgb(var(--pdf-light-box-bg)) !important;
}
.field-container {
color: rgb(var(--pdf-light-simulated-page-text)) !important;
}
/* Thumbnail preview styles */
.thumbnail-container {
width: 140px;
height: 180px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(var(--pdf-light-simulated-page-text), 0.15);
flex-shrink: 0;
background-color: rgb(var(--pdf-light-simulated-page-text));
}
.thumbnail-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-placeholder {
width: 140px;
height: 180px;
border-radius: 12px;
border: 1px dashed rgba(var(--pdf-light-neutral), 0.6);
display: flex;
align-items: center;
justify-content: center;
color: rgb(var(--pdf-light-text-muted));
background: linear-gradient(145deg, var(--mantine-color-gray-1) 0%, var(--mantine-color-gray-0) 100%);
}
@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useOverlayPdfsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('overlay-pdfs.tooltip.header.title', 'Overlay PDFs Overview')
},
tips: [
{
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
description: t(
'overlay-pdfs.tooltip.description.text',
'Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
)
},
{
title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'),
description: t(
'overlay-pdfs.tooltip.mode.text',
'Choose how to distribute overlay pages across the base PDF pages.'
),
bullets: [
t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'),
t('overlay-pdfs.tooltip.mode.interleaved', 'Interleaved Overlay: Take one page from each overlay in turn.'),
t('overlay-pdfs.tooltip.mode.fixedRepeat', 'Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers.')
]
},
{
title: t('overlay-pdfs.tooltip.position.title', 'Overlay Position'),
description: t(
'overlay-pdfs.tooltip.position.text',
'Foreground places the overlay on top of the page. Background places it behind.'
)
},
{
title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'),
description: t(
'overlay-pdfs.tooltip.overlayFiles.text',
'Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
)
},
{
title: t('overlay-pdfs.tooltip.counts.title', 'Counts (Fixed Repeat only)'),
description: t(
'overlay-pdfs.tooltip.counts.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.'
)
}
]
};
};
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Center, Text, ActionIcon } from '@mantine/core';
import { useMantineTheme, useMantineColorScheme } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import { useFileState, useFileActions } from "../../contexts/FileContext";
@@ -20,6 +19,8 @@ export interface EmbedPdfViewerProps {
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File | null;
activeFileIndex?: number;
setActiveFileIndex?: (index: number) => void;
}
const EmbedPdfViewerContent = ({
@@ -27,9 +28,9 @@ const EmbedPdfViewerContent = ({
setSidebarsVisible: _setSidebarsVisible,
onClose,
previewFile,
activeFileIndex: externalActiveFileIndex,
setActiveFileIndex: externalSetActiveFileIndex,
}: EmbedPdfViewerProps) => {
const theme = useMantineTheme();
const { colorScheme: _colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
@@ -52,10 +53,11 @@ const EmbedPdfViewerContent = ({
const { signatureApiRef, historyApiRef } = useSignature();
// Get current file from FileContext
const { selectors } = useFileState();
const { selectors, state } = useFileState();
const { actions } = useFileActions();
const activeFiles = selectors.getFiles();
const activeFileIds = activeFiles.map(f => f.fileId);
const selectedFileIds = state.ui.selectedFileIds;
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
@@ -67,15 +69,40 @@ const EmbedPdfViewerContent = ({
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
// Track which file tab is active
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
const setActiveFileIndex = externalSetActiveFileIndex ?? setInternalActiveFileIndex;
const hasInitializedFromSelection = useRef(false);
// When viewer opens with a selected file, switch to that file
useEffect(() => {
if (!hasInitializedFromSelection.current && selectedFileIds.length > 0 && activeFiles.length > 0) {
const selectedFileId = selectedFileIds[0];
const index = activeFiles.findIndex(f => f.fileId === selectedFileId);
if (index !== -1 && index !== activeFileIndex) {
setActiveFileIndex(index);
}
hasInitializedFromSelection.current = true;
}
}, [selectedFileIds, activeFiles, activeFileIndex]);
// Reset active tab if it's out of bounds
useEffect(() => {
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
setActiveFileIndex(0);
}
}, [activeFiles.length, activeFileIndex]);
// Determine which file to display
const currentFile = React.useMemo(() => {
if (previewFile) {
return previewFile;
} else if (activeFiles.length > 0) {
return activeFiles[0]; // Use first file for simplicity
return activeFiles[activeFileIndex] || activeFiles[0];
}
return null;
}, [previewFile, activeFiles]);
}, [previewFile, activeFiles, activeFileIndex]);
// Get file with URL for rendering
const fileWithUrl = useFileWithUrl(currentFile);
@@ -94,7 +121,7 @@ const EmbedPdfViewerContent = ({
}, [previewFile, fileWithUrl]);
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
React.useEffect(() => {
useEffect(() => {
let accumulator = 0;
const handleWheel = (event: WheelEvent) => {
@@ -128,7 +155,7 @@ const EmbedPdfViewerContent = ({
}, [zoomActions]);
// Handle keyboard zoom shortcuts
React.useEffect(() => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return;
@@ -244,15 +271,6 @@ const EmbedPdfViewerContent = ({
</Center>
) : (
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box p="md" style={{ borderBottom: `1px solid ${theme.colors.gray[3]}` }}>
<Text size="sm" c="dimmed">
Multiple files loaded - showing first file for now
</Text>
</Box>
)}
{/* EmbedPDF Viewer */}
<Box style={{
position: 'relative',
@@ -317,6 +335,7 @@ const EmbedPdfViewerContent = ({
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
activeFileIndex={activeFileIndex}
/>
{/* Navigation Warning Modal */}
@@ -18,7 +18,6 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { Rotation } from '@embedpdf/models';
// Import annotation plugins
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
@@ -67,6 +66,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
const plugins = useMemo(() => {
if (!pdfUrl) return [];
// Calculate 3.5rem in pixels dynamically based on root font size
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const viewportGap = rootFontSize * 3.5;
return [
createPluginRegistration(LoaderPluginPackage, {
loadingOptions: {
@@ -78,7 +81,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
},
}),
createPluginRegistration(ViewportPluginPackage, {
viewportGap: 10,
viewportGap,
}),
createPluginRegistration(ScrollPluginPackage, {
strategy: ScrollStrategy.Vertical,
@@ -134,9 +137,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
createPluginRegistration(ThumbnailPluginPackage),
// Register rotate plugin
createPluginRegistration(RotatePluginPackage, {
defaultRotation: Rotation.Degree0, // Start with no rotation
}),
createPluginRegistration(RotatePluginPackage),
// Register export plugin for downloading PDFs
createPluginRegistration(ExportPluginPackage, {
@@ -288,48 +289,50 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
}}
>
<Scroller
renderPage={({ width, height, pageIndex, scale, rotation }: { width: number; height: number; pageIndex: number; scale: number; rotation?: number }) => (
<Rotate pageSize={{ width, height }}>
<PagePointerProvider {...{ pageWidth: width, pageHeight: height, pageIndex, scale, rotation: rotation || 0 }}>
<div
style={{
width,
height,
position: 'relative',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{/* High-resolution tile layer */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
renderPage={({ document, width, height, pageIndex, scale, rotation }) => {
return (
<Rotate key={document?.id} pageSize={{ width, height }}>
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
<div
style={{
width,
height,
position: 'relative',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{/* High-resolution tile layer */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
{/* Search highlight layer */}
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Search highlight layer */}
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer
pageIndex={pageIndex}
scale={scale}
pageWidth={width}
pageHeight={height}
rotation={rotation || 0}
selectionOutlineColor="#007ACC"
/>
)}
</div>
</PagePointerProvider>
</Rotate>
)}
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer
pageIndex={pageIndex}
scale={scale}
pageWidth={width}
pageHeight={height}
rotation={rotation}
selectionOutlineColor="#007ACC"
/>
)}
</div>
</PagePointerProvider>
</Rotate>
);
}}
/>
</Viewport>
</GlobalPointerProvider>
@@ -64,6 +64,10 @@ export function LocalEmbedPDFWithAnnotations({
const plugins = useMemo(() => {
if (!pdfUrl) return [];
// Calculate 3.5rem in pixels dynamically based on root font size
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const viewportGap = rootFontSize * 3.5;
return [
createPluginRegistration(LoaderPluginPackage, {
loadingOptions: {
@@ -75,7 +79,7 @@ export function LocalEmbedPDFWithAnnotations({
},
}),
createPluginRegistration(ViewportPluginPackage, {
viewportGap: 10,
viewportGap,
}),
createPluginRegistration(ScrollPluginPackage, {
strategy: ScrollStrategy.Vertical,
@@ -5,15 +5,27 @@ import { useViewer } from '../../contexts/ViewerContext';
interface ThumbnailSidebarProps {
visible: boolean;
onToggle: () => void;
activeFileIndex?: number;
}
export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSidebarProps) {
export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex }: ThumbnailSidebarProps) {
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
const scrollState = getScrollState();
const thumbnailAPI = getThumbnailAPI();
// Clear thumbnails when active file changes
useEffect(() => {
// Revoke old blob URLs to prevent memory leaks
Object.values(thumbnails).forEach((thumbUrl) => {
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
URL.revokeObjectURL(thumbUrl);
}
});
setThumbnails({});
}, [activeFileIndex]);
// Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
useEffect(() => {
if (!visible) {
@@ -5,6 +5,8 @@ export interface ViewerProps {
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File | null;
activeFileIndex?: number;
setActiveFileIndex?: (index: number) => void;
}
const Viewer = (props: ViewerProps) => {
@@ -0,0 +1,21 @@
// Central list of formats supported by Convert operations
export const CONVERT_SUPPORTED_FORMATS = [
// Microsoft Office
'doc', 'docx', 'dot', 'dotx', 'csv', 'xls', 'xlsx', 'xlt', 'xltx', 'slk', 'dif', 'ppt', 'pptx',
// OpenDocument
'odt', 'ott', 'ods', 'ots', 'odp', 'otp', 'odg', 'otg',
// Text formats
'txt', 'text', 'xml', 'rtf', 'html', 'lwp', 'md',
// Images
'bmp', 'gif', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'pbm', 'pgm', 'ppm', 'ras', 'xbm', 'xpm', 'svg', 'svm', 'wmf', 'webp',
// StarOffice
'sda', 'sdc', 'sdd', 'sdw', 'stc', 'std', 'sti', 'stw', 'sxd', 'sxg', 'sxi', 'sxw',
// Email formats
'eml',
// Archive formats
'zip',
// Other
'dbf', 'fods', 'vsd', 'vor', 'vor3', 'vor4', 'uop', 'pct', 'ps', 'pdf',
];
+17 -1
View File
@@ -4,9 +4,11 @@ import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
interface RightRailContextValue {
buttons: RightRailButtonConfig[];
actions: Record<string, RightRailAction>;
allButtonsDisabled: boolean;
registerButtons: (buttons: RightRailButtonConfig[]) => void;
unregisterButtons: (ids: string[]) => void;
setAction: (id: string, action: RightRailAction) => void;
setAllRightRailButtonsDisabled: (disabled: boolean) => void;
clear: () => void;
}
@@ -15,6 +17,7 @@ const RightRailContext = createContext<RightRailContextValue | undefined>(undefi
export function RightRailProvider({ children }: { children: React.ReactNode }) {
const [buttons, setButtons] = useState<RightRailButtonConfig[]>([]);
const [actions, setActions] = useState<Record<string, RightRailAction>>({});
const [allButtonsDisabled, setAllButtonsDisabled] = useState<boolean>(false);
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
setButtons(prev => {
@@ -43,12 +46,25 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) {
setActions(prev => ({ ...prev, [id]: action }));
}, []);
const setAllRightRailButtonsDisabled = useCallback((disabled: boolean) => {
setAllButtonsDisabled(disabled);
}, []);
const clear = useCallback(() => {
setButtons([]);
setActions({});
}, []);
const value = useMemo<RightRailContextValue>(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]);
const value = useMemo<RightRailContextValue>(() => ({
buttons,
actions,
allButtonsDisabled,
registerButtons,
unregisterButtons,
setAction,
setAllRightRailButtonsDisabled,
clear
}), [buttons, actions, allButtonsDisabled, registerButtons, unregisterButtons, setAction, setAllRightRailButtonsDisabled, clear]);
return (
<RightRailContext.Provider value={value}>
+3 -1
View File
@@ -7,6 +7,7 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
// All sidebar state management
const quickAccessRef = useRef<HTMLDivElement>(null);
const toolPanelRef = useRef<HTMLDivElement>(null);
const rightRailRef = useRef<HTMLDivElement>(null);
const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
@@ -21,7 +22,8 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
const sidebarRefs: SidebarRefs = useMemo(() => ({
quickAccessRef,
toolPanelRef,
}), [quickAccessRef, toolPanelRef]);
rightRailRef,
}), [quickAccessRef, toolPanelRef, rightRailRef]);
const contextValue: SidebarContextValue = useMemo(() => ({
sidebarState,
+181 -76
View File
@@ -3,74 +3,40 @@
* Eliminates prop drilling with a single, simple context
*/
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
import { useToolManagement } from '../hooks/useToolManagement';
import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy';
import { useNavigationActions, useNavigationState } from './NavigationContext';
import { ToolId, isValidToolId } from '../types/toolId';
import { useNavigationUrlSync } from '../hooks/useUrlSync';
import { getDefaultWorkbench } from '../types/workbench';
import { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '../types/workbench';
import { filterToolRegistryByQuery } from '../utils/toolSearch';
import { useToolHistory } from '../hooks/tools/useUserToolActivity';
import {
ToolWorkflowState,
TOOL_PANEL_MODE_STORAGE_KEY,
createInitialState,
toolWorkflowReducer,
ToolPanelMode,
} from './toolWorkflow/toolWorkflowState';
import { usePreferences } from './PreferencesContext';
// State interface
interface ToolWorkflowState {
// UI State
sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
readerMode: boolean;
// File/Preview State
previewFile: File | null;
pageEditorFunctions: PageEditorFunctions | null;
// Search State
searchQuery: string;
}
// Actions
type ToolWorkflowAction =
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' }
| { type: 'SET_READER_MODE'; payload: boolean }
| { type: 'SET_PREVIEW_FILE'; payload: File | null }
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
| { type: 'SET_SEARCH_QUERY'; payload: string }
| { type: 'RESET_UI_STATE' };
// Initial state
const initialState: ToolWorkflowState = {
sidebarsVisible: true,
leftPanelView: 'toolPicker',
readerMode: false,
previewFile: null,
pageEditorFunctions: null,
searchQuery: '',
};
// Reducer
function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState {
switch (action.type) {
case 'SET_SIDEBARS_VISIBLE':
return { ...state, sidebarsVisible: action.payload };
case 'SET_LEFT_PANEL_VIEW':
return { ...state, leftPanelView: action.payload };
case 'SET_READER_MODE':
return { ...state, readerMode: action.payload };
case 'SET_PREVIEW_FILE':
return { ...state, previewFile: action.payload };
case 'SET_PAGE_EDITOR_FUNCTIONS':
return { ...state, pageEditorFunctions: action.payload };
case 'SET_SEARCH_QUERY':
return { ...state, searchQuery: action.payload };
case 'RESET_UI_STATE':
return { ...initialState, searchQuery: state.searchQuery }; // Preserve search
default:
return state;
}
}
// Types and reducer/state moved to './toolWorkflow/state'
// Context value interface
export interface CustomWorkbenchViewRegistration {
id: string;
workbenchId: WorkbenchType;
label: string;
icon?: React.ReactNode;
component: React.ComponentType<{ data: any }>;
}
export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration {
data: any;
}
interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: ToolId | null;
@@ -82,11 +48,12 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
setSidebarsVisible: (visible: boolean) => void;
setLeftPanelView: (view: 'toolPicker' | 'toolContent' | 'hidden') => void;
setReaderMode: (mode: boolean) => void;
setToolPanelMode: (mode: ToolPanelMode) => void;
setPreviewFile: (file: File | null) => void;
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
setSearchQuery: (query: string) => void;
// Tool Actions
selectTool: (toolId: ToolId | null) => void;
clearToolSelection: () => void;
@@ -103,9 +70,26 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
// Computed values
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
isPanelVisible: boolean;
// Tool History
favoriteTools: ToolId[];
toggleFavorite: (toolId: ToolId) => void;
isFavorite: (toolId: ToolId) => boolean;
customWorkbenchViews: CustomWorkbenchViewInstance[];
registerCustomWorkbenchView: (view: CustomWorkbenchViewRegistration) => void;
unregisterCustomWorkbenchView: (id: string) => void;
setCustomWorkbenchViewData: (id: string, data: any) => void;
clearCustomWorkbenchViewData: (id: string) => void;
}
const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(undefined);
// Ensure a single context instance across HMR to avoid provider/consumer mismatches
const __GLOBAL_CONTEXT_KEY__ = '__ToolWorkflowContext__';
const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as React.Context<ToolWorkflowContextValue | undefined> | undefined;
const ToolWorkflowContext = existingContext ?? createContext<ToolWorkflowContextValue | undefined>(undefined);
if (!existingContext) {
(globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
}
// Provider component
interface ToolWorkflowProviderProps {
@@ -113,11 +97,15 @@ interface ToolWorkflowProviderProps {
}
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
const [state, dispatch] = useReducer(toolWorkflowReducer, undefined, createInitialState);
const { preferences } = usePreferences();
// Store reset functions for tools
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
const [customViewRegistry, setCustomViewRegistry] = React.useState<Record<string, CustomWorkbenchViewRegistration>>({});
const [customViewData, setCustomViewData] = React.useState<Record<string, any>>({});
// Navigation actions and state are available since we're inside NavigationProvider
const { actions } = useNavigationActions();
const navigationState = useNavigationState();
@@ -128,6 +116,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
getSelectedTool,
} = useToolManagement();
// Tool history hook
const {
favoriteTools,
toggleFavorite,
isFavorite,
} = useToolHistory();
// Get selected tool from navigation context
const selectedTool = getSelectedTool(navigationState.selectedTool);
@@ -148,6 +143,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_READER_MODE', payload: mode });
}, [actions]);
const setToolPanelMode = useCallback((mode: ToolPanelMode) => {
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode });
}, []);
const setPreviewFile = useCallback((file: File | null) => {
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
if (file) {
@@ -163,6 +163,93 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
}, []);
const registerCustomWorkbenchView = useCallback((view: CustomWorkbenchViewRegistration) => {
setCustomViewRegistry(prev => ({ ...prev, [view.id]: view }));
}, []);
const unregisterCustomWorkbenchView = useCallback((id: string) => {
let removedView: CustomWorkbenchViewRegistration | undefined;
setCustomViewRegistry(prev => {
const existing = prev[id];
if (!existing) {
return prev;
}
removedView = existing;
const updated = { ...prev };
delete updated[id];
return updated;
});
setCustomViewData(prev => {
if (!(id in prev)) {
return prev;
}
const updated = { ...prev };
delete updated[id];
return updated;
});
if (removedView && navigationState.workbench === removedView.workbenchId) {
actions.setWorkbench(getDefaultWorkbench());
}
}, [actions, navigationState.workbench]);
const setCustomWorkbenchViewData = useCallback((id: string, data: any) => {
setCustomViewData(prev => ({ ...prev, [id]: data }));
}, []);
const clearCustomWorkbenchViewData = useCallback((id: string) => {
setCustomViewData(prev => {
if (!(id in prev)) {
return prev;
}
const updated = { ...prev };
delete updated[id];
return updated;
});
}, []);
const customWorkbenchViews = useMemo<CustomWorkbenchViewInstance[]>(() => {
return Object.values(customViewRegistry).map(view => ({
...view,
data: Object.prototype.hasOwnProperty.call(customViewData, view.id) ? customViewData[view.id] : null,
}));
}, [customViewRegistry, customViewData]);
useEffect(() => {
if (isBaseWorkbench(navigationState.workbench)) {
return;
}
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
if (!currentCustomView || currentCustomView.data == null) {
actions.setWorkbench(getDefaultWorkbench());
}
}, [actions, customWorkbenchViews, navigationState.workbench]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, state.toolPanelMode);
}, [state.toolPanelMode]);
// Keep tool panel mode in sync with user preference. This ensures the
// Config setting (Default tool picker mode) immediately affects the app
// and persists across reloads.
useEffect(() => {
if (!preferences) return;
const preferredMode = preferences.defaultToolPanelMode;
if (preferredMode && preferredMode !== state.toolPanelMode) {
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: preferredMode });
if (typeof window !== 'undefined') {
window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, preferredMode);
}
}
}, [preferences.defaultToolPanelMode]);
// Tool reset methods
const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => {
setToolResetFunctions(prev => ({ ...prev, [toolId]: resetFunction }));
@@ -181,21 +268,25 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: ToolId) => {
// If we're currently on a custom workbench (e.g., Validate Signature report),
// selecting any tool should take the user back to the default file manager view.
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
// Handle read tool selection - should behave exactly like QuickAccessBar read button
if (toolId === 'read') {
setReaderMode(true);
actions.setSelectedTool('read');
actions.setWorkbench('viewer');
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'viewer');
setSearchQuery('');
return;
}
// Handle multiTool selection - enable page editor workbench and hide left panel
// Handle multiTool selection - enable page editor workbench
if (toolId === 'multiTool') {
setReaderMode(false);
setLeftPanelView('hidden');
actions.setSelectedTool('multiTool');
actions.setWorkbench('pageEditor');
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'pageEditor');
setSearchQuery('');
return;
}
@@ -206,7 +297,9 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Get the tool from registry to determine workbench
const tool = getSelectedTool(toolId);
if (tool && tool.workbench) {
if (wasInCustomWorkbench) {
actions.setWorkbench(getDefaultWorkbench());
} else if (tool && tool.workbench) {
actions.setWorkbench(tool.workbench);
} else {
actions.setWorkbench(getDefaultWorkbench());
@@ -216,7 +309,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery('');
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
@@ -239,15 +332,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
[state.sidebarsVisible, state.readerMode, state.leftPanelView]
);
// URL sync for proper tool navigation
useNavigationUrlSync(
navigationState.selectedTool,
handleToolSelect,
handleBackToTools,
toolRegistry as ToolRegistry,
true
);
// Properly memoized context value
const contextValue = useMemo((): ToolWorkflowContextValue => ({
// State
@@ -261,6 +345,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
setToolPanelMode,
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
@@ -280,6 +365,18 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Computed
filteredTools,
isPanelVisible,
// Tool History
favoriteTools,
toggleFavorite,
isFavorite,
// Custom workbench views
customWorkbenchViews,
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
}), [
state,
navigationState.selectedTool,
@@ -289,6 +386,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
setToolPanelMode,
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
@@ -300,6 +398,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
handleReaderToggle,
filteredTools,
isPanelVisible,
favoriteTools,
toggleFavorite,
isFavorite,
customWorkbenchViews,
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
]);
return (
@@ -313,7 +419,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext);
if (!context) {
console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
+9
View File
@@ -132,6 +132,10 @@ interface ViewerContextType {
setAnnotationMode: (enabled: boolean) => void;
toggleAnnotationMode: () => void;
// Active file index for multi-file viewing
activeFileIndex: number;
setActiveFileIndex: (index: number) => void;
// State getters - read current state from bridges
getScrollState: () => ScrollState;
getZoomState: () => ZoomState;
@@ -219,6 +223,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0);
// Get current navigation state to check if we're in sign mode
useNavigation();
@@ -577,6 +582,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setAnnotationMode,
toggleAnnotationMode,
// Active file index
activeFileIndex,
setActiveFileIndex,
// State getters
getScrollState,
getZoomState,
@@ -0,0 +1,88 @@
import { PageEditorFunctions } from '../../types/pageEditor';
// State & Modes
export type ToolPanelMode = 'sidebar' | 'fullscreen';
export interface ToolWorkflowState {
// UI State
sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
readerMode: boolean;
toolPanelMode: ToolPanelMode;
previewFile: File | null;
pageEditorFunctions: PageEditorFunctions | null;
// Search State
searchQuery: string;
}
// Actions
export type ToolWorkflowAction =
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' }
| { type: 'SET_READER_MODE'; payload: boolean }
| { type: 'SET_TOOL_PANEL_MODE'; payload: ToolPanelMode }
| { type: 'SET_PREVIEW_FILE'; payload: File | null }
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
| { type: 'SET_SEARCH_QUERY'; payload: string }
| { type: 'RESET_UI_STATE' };
// Storage keys
export const TOOL_PANEL_MODE_STORAGE_KEY = 'toolPanelModePreference';
export const getStoredToolPanelMode = (): ToolPanelMode => {
if (typeof window === 'undefined') {
return 'sidebar';
}
const stored = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY);
if (stored === 'fullscreen') {
return 'fullscreen';
}
return 'sidebar';
};
export const baseState: Omit<ToolWorkflowState, 'toolPanelMode'> = {
sidebarsVisible: true,
leftPanelView: 'toolPicker',
readerMode: false,
previewFile: null,
pageEditorFunctions: null,
searchQuery: '',
};
export const createInitialState = (): ToolWorkflowState => ({
...baseState,
toolPanelMode: getStoredToolPanelMode(),
});
export function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState {
switch (action.type) {
case 'SET_SIDEBARS_VISIBLE':
return { ...state, sidebarsVisible: action.payload };
case 'SET_LEFT_PANEL_VIEW':
return { ...state, leftPanelView: action.payload };
case 'SET_READER_MODE':
return { ...state, readerMode: action.payload };
case 'SET_TOOL_PANEL_MODE':
return { ...state, toolPanelMode: action.payload };
case 'SET_PREVIEW_FILE':
return { ...state, previewFile: action.payload };
case 'SET_PAGE_EDITOR_FUNCTIONS':
return { ...state, pageEditorFunctions: action.payload };
case 'SET_SEARCH_QUERY':
return { ...state, searchQuery: action.payload };
case 'RESET_UI_STATE':
return {
...baseState,
toolPanelMode: state.toolPanelMode,
searchQuery: state.searchQuery,
};
default:
return state;
}
}
+51 -11
View File
@@ -4,6 +4,17 @@ import { ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
import { BaseToolProps } from '../types/tool';
import { WorkbenchType } from '../types/workbench';
import { ToolId } from '../types/toolId';
import DrawRoundedIcon from '@mui/icons-material/DrawRounded';
import SecurityRoundedIcon from '@mui/icons-material/SecurityRounded';
import VerifiedUserRoundedIcon from '@mui/icons-material/VerifiedUserRounded';
import RateReviewRoundedIcon from '@mui/icons-material/RateReviewRounded';
import ViewAgendaRoundedIcon from '@mui/icons-material/ViewAgendaRounded';
import FileDownloadRoundedIcon from '@mui/icons-material/FileDownloadRounded';
import DeleteSweepRoundedIcon from '@mui/icons-material/DeleteSweepRounded';
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
export enum SubcategoryId {
SIGNING = 'signing',
@@ -66,17 +77,46 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [
];
export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
[SubcategoryId.SIGNING]: '#FF7892',
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
[SubcategoryId.VERIFICATION]: '#1BB1D4',
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
[SubcategoryId.EXTRACTION]: '#1BB1D4',
[SubcategoryId.REMOVAL]: '#7882FF',
[SubcategoryId.AUTOMATION]: '#69DC95',
[SubcategoryId.GENERAL]: '#69DC95',
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
[SubcategoryId.SIGNING]: 'var(--category-color-signing)', // Green
[SubcategoryId.DOCUMENT_SECURITY]: 'var(--category-color-security)', // Orange
[SubcategoryId.VERIFICATION]: 'var(--category-color-verification)', // Orange
[SubcategoryId.DOCUMENT_REVIEW]: 'var(--category-color-general)', // Blue
[SubcategoryId.PAGE_FORMATTING]: 'var(--category-color-formatting)', // Purple
[SubcategoryId.EXTRACTION]: 'var(--category-color-extraction)', // Cyan
[SubcategoryId.REMOVAL]: 'var(--category-color-removal)', // Red
[SubcategoryId.AUTOMATION]: 'var(--category-color-automation)', // Pink
[SubcategoryId.GENERAL]: 'var(--category-color-general)', // Blue
[SubcategoryId.ADVANCED_FORMATTING]: 'var(--category-color-formatting)', // Purple
[SubcategoryId.DEVELOPER_TOOLS]: 'var(--category-color-developer)', // Gray
};
export const getSubcategoryIcon = (subcategory: SubcategoryId): React.ReactNode => {
switch (subcategory) {
case SubcategoryId.SIGNING:
return React.createElement(DrawRoundedIcon);
case SubcategoryId.DOCUMENT_SECURITY:
return React.createElement(SecurityRoundedIcon);
case SubcategoryId.VERIFICATION:
return React.createElement(VerifiedUserRoundedIcon);
case SubcategoryId.DOCUMENT_REVIEW:
return React.createElement(RateReviewRoundedIcon);
case SubcategoryId.PAGE_FORMATTING:
return React.createElement(ViewAgendaRoundedIcon);
case SubcategoryId.EXTRACTION:
return React.createElement(FileDownloadRoundedIcon);
case SubcategoryId.REMOVAL:
return React.createElement(DeleteSweepRoundedIcon);
case SubcategoryId.AUTOMATION:
return React.createElement(SmartToyRoundedIcon);
case SubcategoryId.GENERAL:
return React.createElement(BuildRoundedIcon);
case SubcategoryId.ADVANCED_FORMATTING:
return React.createElement(TuneRoundedIcon);
case SubcategoryId.DEVELOPER_TOOLS:
return React.createElement(CodeRoundedIcon);
default:
return React.createElement(BuildRoundedIcon);
}
};
export const getCategoryLabel = (t: TFunction, id: ToolCategoryId): string => t(`toolPicker.categories.${id}`, id);
+17 -88
View File
@@ -66,6 +66,10 @@ import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useEx
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
import { removePagesOperationConfig } from "../hooks/tools/removePages/useRemovePagesOperation";
import { removeBlanksOperationConfig } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation";
import { overlayPdfsOperationConfig } from "../hooks/tools/overlayPdfs/useOverlayPdfsOperation";
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
@@ -81,16 +85,14 @@ import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import ReplaceColor from "../tools/ReplaceColor";
import ScannerImageSplit from "../tools/ScannerImageSplit";
import OverlayPdfs from "../tools/OverlayPdfs";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
import SignSettings from "../components/tools/sign/SignSettings";
import AddPageNumbers from "../tools/AddPageNumbers";
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import RemoveAnnotations from "../tools/RemoveAnnotations";
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings";
import ExtractImages from "../tools/ExtractImages";
@@ -105,89 +107,13 @@ import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttach
import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings";
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSettings";
import ValidateSignature from "../tools/ValidateSignature";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
// Convert tool supported file formats
export const CONVERT_SUPPORTED_FORMATS = [
// Microsoft Office
"doc",
"docx",
"dot",
"dotx",
"csv",
"xls",
"xlsx",
"xlt",
"xltx",
"slk",
"dif",
"ppt",
"pptx",
// OpenDocument
"odt",
"ott",
"ods",
"ots",
"odp",
"otp",
"odg",
"otg",
// Text formats
"txt",
"text",
"xml",
"rtf",
"html",
"lwp",
"md",
// Images
"bmp",
"gif",
"jpeg",
"jpg",
"png",
"tif",
"tiff",
"pbm",
"pgm",
"ppm",
"ras",
"xbm",
"xpm",
"svg",
"svm",
"wmf",
"webp",
// StarOffice
"sda",
"sdc",
"sdd",
"sdw",
"stc",
"std",
"sti",
"stw",
"sxd",
"sxg",
"sxi",
"sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf",
"fods",
"vsd",
"vor",
"vor3",
"vor4",
"uop",
"pct",
"ps",
"pdf",
];
import { CONVERT_SUPPORTED_FORMATS } from "../constants/convertSupportedFornats";
// Hook to get the translated tool registry
export function useFlatToolRegistry(): ToolRegistry {
@@ -356,10 +282,12 @@ export function useFlatToolRegistry(): ToolRegistry {
validateSignature: {
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.validateSignature.title", "Validate PDF Signature"),
component: null,
component: ValidateSignature,
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
maxFiles: -1,
endpoints: ["validate-signature"],
synonyms: getSynonyms(t, "validateSignature"),
automationSettings: null
},
@@ -704,13 +632,14 @@ export function useFlatToolRegistry(): ToolRegistry {
},
overlayPdfs: {
icon: <LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.overlayPdfs.title", "Overlay PDFs"),
component: null,
description: t("home.overlayPdfs.desc", "Overlay one PDF on top of another"),
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
component: OverlayPdfs,
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "overlayPdfs"),
automationSettings: null
operationConfig: overlayPdfsOperationConfig,
synonyms: getSynonyms(t, "overlay-pdfs"),
automationSettings: OverlayPdfsSettings
},
replaceColor: {
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType, type ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { type OverlayPdfsParameters } from './useOverlayPdfsParameters';
const buildFormData = (parameters: OverlayPdfsParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Overlay files
for (const overlay of parameters.overlayFiles || []) {
formData.append('overlayFiles', overlay);
}
// Mode and position
formData.append('overlayMode', parameters.overlayMode);
formData.append('overlayPosition', String(parameters.overlayPosition));
// Counts (only relevant for FixedRepeatOverlay, server accepts repeated 'counts' fields)
if (parameters.overlayMode === 'FixedRepeatOverlay') {
for (const count of parameters.counts || []) {
formData.append('counts', String(count));
}
}
return formData;
};
export const overlayPdfsOperationConfig: ToolOperationConfig<OverlayPdfsParameters> = {
toolType: ToolType.singleFile,
buildFormData,
operationType: 'overlayPdfs',
endpoint: '/api/v1/general/overlay-pdfs'
};
export const useOverlayPdfsOperation = () => {
const { t } = useTranslation();
return useToolOperation<OverlayPdfsParameters>({
...overlayPdfsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('overlay-pdfs.error.failed', 'An error occurred while overlaying PDFs.')
),
});
};
@@ -0,0 +1,37 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, type BaseParametersHook } from '../shared/useBaseParameters';
export type OverlayMode = 'SequentialOverlay' | 'InterleavedOverlay' | 'FixedRepeatOverlay';
export interface OverlayPdfsParameters extends BaseParameters {
overlayFiles: File[];
overlayMode: OverlayMode;
overlayPosition: 0 | 1;
counts: number[];
}
export const defaultParameters: OverlayPdfsParameters = {
overlayFiles: [],
overlayMode: 'SequentialOverlay',
overlayPosition: 0,
counts: []
};
export type OverlayPdfsParametersHook = BaseParametersHook<OverlayPdfsParameters>;
export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
return useBaseParameters<OverlayPdfsParameters>({
defaultParameters,
endpointName: 'overlay-pdfs',
validateFn: (params) => {
if (!params.overlayFiles || params.overlayFiles.length === 0) return false;
if (params.overlayMode === 'FixedRepeatOverlay') {
if (!params.counts || params.counts.length !== params.overlayFiles.length) return false;
if (params.counts.some((c) => !Number.isFinite(c) || c <= 0)) return false;
}
return true;
},
});
};
@@ -2,12 +2,9 @@ import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveAnnotationsParameters, defaultParameters } from './useRemoveAnnotationsParameters';
import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
// Client-side PDF processing using PDF-lib
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<File[]> => {
// Dynamic import of PDF-lib for client-side processing
const { PDFDocument, PDFName, PDFRef, PDFDict } = await import('pdf-lib');
const processedFiles: File[] = [];
for (const file of files) {
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { ToolId } from '../../types/toolId';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
export function useFavoriteToolItems(
favoriteTools: ToolId[],
toolRegistry: Partial<Record<ToolId, ToolRegistryEntry>>
): Array<{ id: ToolId; tool: ToolRegistryEntry }> {
return useMemo(() => {
return favoriteTools
.map((toolId) => {
const tool = toolRegistry[toolId as ToolId];
return tool ? { id: toolId as ToolId, tool } : null;
})
.filter((x): x is { id: ToolId; tool: ToolRegistryEntry } => x !== null)
.filter(({ id, tool }) => Boolean(tool.component) || Boolean(tool.link) || id === 'read' || id === 'multiTool');
}, [favoriteTools, toolRegistry]);
}
@@ -0,0 +1,30 @@
import { useState, useEffect, Dispatch, SetStateAction } from 'react';
export function useLocalStorageState<T>(key: string, defaultValue: T): [T, Dispatch<SetStateAction<T>>] {
const [state, setState] = useState<T>(() => {
if (typeof window === 'undefined') {
return defaultValue;
}
const stored = window.localStorage.getItem(key);
if (stored === null) {
return defaultValue;
}
try {
return JSON.parse(stored) as T;
} catch {
return defaultValue;
}
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
@@ -0,0 +1,119 @@
import { useLayoutEffect, useState, RefObject, useRef } from 'react';
export interface ToolPanelGeometry {
left: number;
top: number;
width: number;
height: number;
}
interface UseToolPanelGeometryOptions {
enabled: boolean;
toolPanelRef: RefObject<HTMLDivElement | null>;
quickAccessRef: RefObject<HTMLDivElement | null>;
rightRailRef?: RefObject<HTMLDivElement | null>;
}
export function useToolPanelGeometry({
enabled,
toolPanelRef,
quickAccessRef,
rightRailRef,
}: UseToolPanelGeometryOptions) {
const [geometry, setGeometry] = useState<ToolPanelGeometry | null>(null);
const scheduleUpdateRef = useRef<() => void>(() => {});
useLayoutEffect(() => {
if (!enabled) {
setGeometry(null);
return;
}
const panelEl = toolPanelRef.current;
if (!panelEl) {
setGeometry(null);
return;
}
const rightRailEl = () => (rightRailRef?.current ?? null);
let rafId: number | null = null;
const computeAndSetGeometry = () => {
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
};
const scheduleUpdate = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
computeAndSetGeometry();
rafId = null;
});
};
scheduleUpdateRef.current = scheduleUpdate;
// Initial geometry calculation (no debounce)
computeAndSetGeometry();
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => scheduleUpdate());
resizeObserver.observe(panelEl);
if (quickAccessRef.current) {
resizeObserver.observe(quickAccessRef.current);
}
const rail = rightRailEl();
if (rail) {
resizeObserver.observe(rail);
}
// Observe root element to react to viewport-driven layout changes
if (document.documentElement) {
resizeObserver.observe(document.documentElement);
}
} else {
// Fallback for environments without ResizeObserver
const handleResize = () => scheduleUpdate();
window.addEventListener('resize', handleResize);
// Ensure cleanup of the fallback listener
resizeObserver = {
disconnect: () => window.removeEventListener('resize', handleResize),
} as unknown as ResizeObserver;
}
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
scheduleUpdateRef.current = () => {};
resizeObserver?.disconnect();
};
}, [enabled, quickAccessRef, toolPanelRef, rightRailRef]);
// Secondary effect: (re)attach observers when refs' .current become available later
useLayoutEffect(() => {
if (!enabled) return;
if (typeof ResizeObserver === 'undefined') return;
const qa = quickAccessRef.current;
const rail = rightRailRef?.current ?? null;
if (!qa && !rail) return;
const ro = new ResizeObserver(() => scheduleUpdateRef.current());
if (qa) ro.observe(qa);
if (rail) ro.observe(rail);
return () => ro.disconnect();
}, [enabled, quickAccessRef.current, rightRailRef?.current]);
return geometry;
}
@@ -0,0 +1,70 @@
import { useState, useEffect, useCallback } from 'react';
import { ToolId } from '../../types/toolId';
const RECENT_TOOLS_KEY = 'stirlingpdf.recentTools';
const FAVORITE_TOOLS_KEY = 'stirlingpdf.favoriteTools';
export function useToolHistory() {
const [recentTools, setRecentTools] = useState<ToolId[]>([]);
const [favoriteTools, setFavoriteTools] = useState<ToolId[]>([]);
// Load from localStorage on mount
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const recentStr = window.localStorage.getItem(RECENT_TOOLS_KEY);
const favoritesStr = window.localStorage.getItem(FAVORITE_TOOLS_KEY);
if (recentStr) {
try {
const recent = JSON.parse(recentStr) as ToolId[];
setRecentTools(recent);
} catch {
// Ignore parse errors
}
}
if (favoritesStr) {
try {
const favorites = JSON.parse(favoritesStr) as ToolId[];
setFavoriteTools(favorites);
} catch {
// Ignore parse errors
}
}
}, []);
// Toggle favorite status
const toggleFavorite = useCallback((toolId: ToolId) => {
if (typeof window === 'undefined') {
return;
}
setFavoriteTools((prev) => {
const isFavorite = prev.includes(toolId);
const updated = isFavorite
? prev.filter((id) => id !== toolId)
: [...prev, toolId];
window.localStorage.setItem(FAVORITE_TOOLS_KEY, JSON.stringify(updated));
return updated;
});
}, []);
// Check if a tool is favorited
const isFavorite = useCallback(
(toolId: ToolId): boolean => {
return favoriteTools.includes(toolId);
},
[favoriteTools]
);
return {
recentTools,
favoriteTools,
toggleFavorite,
isFavorite,
};
}
@@ -0,0 +1,68 @@
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
import { wrapText } from '../utils/pdfText';
import { colorPalette } from '../utils/pdfPalette';
interface DrawCenteredMessageOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
text: string;
description: string;
marginX: number;
contentWidth: number;
cursorY: number;
badgeColor: ReturnType<typeof rgb>;
}
export const drawCenteredMessage = ({
page,
font,
fontBold,
text,
description,
marginX,
contentWidth,
cursorY,
badgeColor,
}: DrawCenteredMessageOptions): number => {
const badgeFontSize = 10;
const badgePaddingX = 14;
const badgePaddingY = 6;
const badgeWidth = font.widthOfTextAtSize(text, badgeFontSize) + badgePaddingX * 2;
const badgeHeight = badgeFontSize + badgePaddingY * 2;
const badgeX = marginX + (contentWidth - badgeWidth) / 2;
page.drawRectangle({
x: badgeX,
y: cursorY - badgeHeight,
width: badgeWidth,
height: badgeHeight,
color: badgeColor,
});
page.drawText(text, {
x: badgeX + badgePaddingX,
y: cursorY - badgePaddingY - badgeFontSize + 2,
size: badgeFontSize,
font: fontBold,
color: rgb(1, 1, 1),
});
let nextCursor = cursorY - 32;
const lines = wrapText(description, font, 11, contentWidth * 0.75);
lines.forEach((line) => {
const lineWidth = font.widthOfTextAtSize(line, 11);
const lineX = marginX + (contentWidth - lineWidth) / 2;
page.drawText(line, {
x: lineX,
y: nextCursor,
size: 11,
font,
color: colorPalette.textPrimary,
});
nextCursor -= 18;
});
return nextCursor - 8;
};
@@ -0,0 +1,70 @@
import { PDFFont, PDFPage } from 'pdf-lib';
import { wrapText } from '../utils/pdfText';
import { colorPalette } from '../utils/pdfPalette';
interface FieldBoxOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
x: number;
top: number;
width: number;
label: string;
value: string;
}
export const drawFieldBox = ({
page,
font,
fontBold,
x,
top,
width,
label,
value,
}: FieldBoxOptions): number => {
const labelFontSize = 8;
const valueFontSize = 11;
const valueLineHeight = valueFontSize * 1.25;
const boxPadding = 6;
page.drawText(label.toUpperCase(), {
x,
y: top - labelFontSize,
size: labelFontSize,
font: fontBold,
color: colorPalette.textMuted,
});
const boxTop = top - labelFontSize - 6;
const rawValue = value && value.trim().length > 0 ? value : '--';
const lines = wrapText(rawValue, font, valueFontSize, width - boxPadding * 2);
const boxHeight = Math.max(valueLineHeight, lines.length * valueLineHeight) + boxPadding * 2;
page.drawRectangle({
x,
y: boxTop - boxHeight,
width,
height: boxHeight,
color: colorPalette.boxBackground,
borderColor: colorPalette.boxBorder,
});
let textY = boxTop - boxPadding - valueFontSize;
lines.forEach((line) => {
const lineWidth = font.widthOfTextAtSize(line, valueFontSize);
const available = width - boxPadding * 2;
const centeredX = x + boxPadding + Math.max(0, (available - lineWidth) / 2);
page.drawText(line, {
x: centeredX,
y: textY,
size: valueFontSize,
font,
color: colorPalette.textPrimary,
});
textY -= valueLineHeight;
});
return labelFontSize + 6 + boxHeight + 6;
};
@@ -0,0 +1,173 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFPage } from 'pdf-lib';
import { SignatureValidationSignature } from '../../../../types/validateSignature';
import { drawFieldBox } from './FieldBoxSection';
import { drawStatusBadge } from './StatusBadgeSection';
import { computeSignatureStatus, statusKindToPdfColor } from '../utils/signatureStatus';
import { formatDate } from '../utils/pdfText';
import { colorPalette } from '../utils/pdfPalette';
interface DrawSignatureSectionOptions {
page: PDFPage;
cursorY: number;
signature: SignatureValidationSignature;
index: number;
marginX: number;
contentWidth: number;
columnGap: number;
font: PDFFont;
fontBold: PDFFont;
t: TFunction<'translation'>;
}
export const drawSignatureSection = ({
page,
cursorY,
signature,
index,
marginX,
contentWidth,
columnGap,
font,
fontBold,
t,
}: DrawSignatureSectionOptions): number => {
const columnWidth = (contentWidth - columnGap) / 2;
const heading = `${t('validateSignature.signature._value', 'Signature')} ${index + 1}`;
page.drawText(heading, {
x: marginX,
y: cursorY,
size: 14,
font: fontBold,
color: colorPalette.textPrimary,
});
const status = computeSignatureStatus(signature, t);
const statusColor = statusKindToPdfColor(status.kind);
const headingWidth = fontBold.widthOfTextAtSize(heading, 14);
drawStatusBadge({
page,
font,
fontBold,
text: status.label,
x: marginX + headingWidth + 16,
y: cursorY + 14,
color: statusColor,
});
let nextY = cursorY - 20;
const signatureFields = [
{ label: t('validateSignature.signer', 'Signer'), value: signature.signerName || '-' },
{ label: t('validateSignature.date', 'Date'), value: formatDate(signature.signatureDate) },
{ label: t('validateSignature.reason', 'Reason'), value: signature.reason || '-' },
{ label: t('validateSignature.location', 'Location'), value: signature.location || '-' },
];
for (let i = 0; i < signatureFields.length; i += 2) {
const leftField = signatureFields[i];
const rightField = signatureFields[i + 1];
const leftHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX,
top: nextY,
width: columnWidth,
label: leftField.label,
value: leftField.value,
});
let rowHeight = leftHeight;
if (rightField) {
const rightHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX + columnWidth + columnGap,
top: nextY,
width: columnWidth,
label: rightField.label,
value: rightField.value,
});
rowHeight = Math.max(leftHeight, rightHeight);
}
nextY -= rowHeight + 8;
}
nextY -= 6;
page.drawLine({
start: { x: marginX, y: nextY },
end: { x: marginX + contentWidth, y: nextY },
thickness: 1,
color: colorPalette.boxBorder,
});
nextY -= 20;
const certificateFields = [
{ label: t('validateSignature.cert.issuer', 'Issuer'), value: signature.issuerDN || '-' },
{ label: t('validateSignature.cert.subject', 'Subject'), value: signature.subjectDN || '-' },
{ label: t('validateSignature.cert.serialNumber', 'Serial Number'), value: signature.serialNumber || '-' },
{ label: t('validateSignature.cert.algorithm', 'Algorithm'), value: signature.signatureAlgorithm || '-' },
{ label: t('validateSignature.cert.validFrom', 'Valid From'), value: formatDate(signature.validFrom) },
{ label: t('validateSignature.cert.validUntil', 'Valid Until'), value: formatDate(signature.validUntil) },
{
label: t('validateSignature.cert.keySize', 'Key Size'),
value:
signature.keySize != null
? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}`
: '--',
},
{ label: t('validateSignature.cert.version', 'Version'), value: signature.version || '-' },
{
label: t('validateSignature.cert.keyUsage', 'Key Usage'),
value:
signature.keyUsages && signature.keyUsages.length > 0
? signature.keyUsages.join(', ')
: '--',
},
{
label: t('validateSignature.cert.selfSigned', 'Self-Signed'),
value: signature.selfSigned ? t('yes', 'Yes') : t('no', 'No'),
},
];
for (let i = 0; i < certificateFields.length; i += 2) {
const leftField = certificateFields[i];
const rightField = certificateFields[i + 1];
const leftHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX,
top: nextY,
width: columnWidth,
label: leftField.label,
value: leftField.value,
});
let rowHeight = leftHeight;
if (rightField) {
const rightHeight = drawFieldBox({
page,
font,
fontBold,
x: marginX + columnWidth + columnGap,
top: nextY,
width: columnWidth,
label: rightField.label,
value: rightField.value,
});
rowHeight = Math.max(leftHeight, rightHeight);
}
nextY -= rowHeight + 8;
}
return nextY - 12;
};
@@ -0,0 +1,38 @@
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
interface StatusBadgeOptions {
page: PDFPage;
font: PDFFont;
fontBold: PDFFont;
text: string;
x: number;
y: number;
color: ReturnType<typeof rgb>;
}
export const drawStatusBadge = ({ page, font, fontBold, text, x, y, color }: StatusBadgeOptions): number => {
const paddingX = 14;
const paddingY = 6;
const fontSize = 10;
const textWidth = font.widthOfTextAtSize(text, fontSize);
const width = textWidth + paddingX * 2;
const height = fontSize + paddingY * 2;
page.drawRectangle({
x,
y: y - height,
width,
height,
color,
});
page.drawText(text, {
x: x + paddingX,
y: y - paddingY - fontSize + 2,
size: fontSize,
font: fontBold,
color: rgb(1, 1, 1),
});
return width;
};
@@ -0,0 +1,145 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFImage, PDFPage } from 'pdf-lib';
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
import { drawFieldBox } from './FieldBoxSection';
import { drawThumbnailImage, drawThumbnailPlaceholder } from './ThumbnailSection';
import { colorPalette } from '../utils/pdfPalette';
import { formatFileSize } from '../utils/pdfText';
interface DrawSummarySectionOptions {
page: PDFPage;
cursorY: number;
entry: SignatureValidationReportEntry;
font: PDFFont;
fontBold: PDFFont;
marginX: number;
contentWidth: number;
columnGap: number;
statusText: string;
statusColor: (typeof colorPalette)['success'];
loadThumbnail: (url: string) => Promise<{ image: PDFImage } | null>;
t: TFunction<'translation'>;
}
export const drawSummarySection = async ({
page,
cursorY,
entry,
font,
fontBold,
marginX,
contentWidth,
columnGap,
loadThumbnail,
t,
}: DrawSummarySectionOptions): Promise<number> => {
const thumbnailWidth = 140;
const thumbnailHeight = 180;
const summaryX = marginX + thumbnailWidth + 24;
const summaryWidth = contentWidth - (thumbnailWidth + 24);
const summaryColumnWidth = (summaryWidth - columnGap) / 2;
const rowSpacing = 8;
const summaryTop = cursorY;
const titleFontSize = 22;
const subtitleFontSize = 11;
const latestSignatureTimestamp = entry.signatures
.map((sig) => (sig.signatureDate ? Date.parse(sig.signatureDate) : NaN))
.filter((value) => !Number.isNaN(value));
const latestSignatureLabel = latestSignatureTimestamp.length
? new Date(Math.max(...latestSignatureTimestamp)).toLocaleString()
: '--';
const titleBaseline = summaryTop - 12 - titleFontSize;
page.drawText(entry.fileName, {
x: summaryX,
y: titleBaseline,
size: titleFontSize,
font: fontBold,
color: colorPalette.textPrimary,
});
const subtitle = t('validateSignature.report.shortTitle', 'Signature Summary');
const subtitleBaseline = titleBaseline - subtitleFontSize - 6;
page.drawText(subtitle, {
x: summaryX,
y: subtitleBaseline,
size: subtitleFontSize,
font,
color: colorPalette.textMuted,
});
const summaryRows: Array<
Array<{
label: string;
value: string;
}>
> = [
[
{ label: t('validateSignature.report.fields.fileSize', 'File Size'), value: formatFileSize(entry.fileSize) },
{ label: t('validateSignature.report.fields.created', 'Created'), value: entry.createdAtLabel ?? '--' },
],
[
{ label: t('validateSignature.report.fields.signatureDate', 'Signature Date'), value: latestSignatureLabel },
{ label: t('validateSignature.report.fields.signatureCount', 'Total Signatures'), value: entry.signatures.length.toString() },
],
];
let rowTop = subtitleBaseline - subtitleFontSize - 18;
summaryRows.forEach((fields, rowIndex) => {
let rowHeight = 0;
const singleColumn = fields.length === 1;
fields.forEach((field, index) => {
const fieldWidth = singleColumn ? summaryWidth : summaryColumnWidth;
const x = singleColumn ? summaryX : summaryX + index * (summaryColumnWidth + columnGap);
const fieldHeight = drawFieldBox({
page,
font,
fontBold,
x,
top: rowTop,
width: fieldWidth,
label: field.label,
value: field.value,
});
rowHeight = Math.max(rowHeight, fieldHeight);
});
rowTop -= rowHeight;
if (rowIndex < summaryRows.length - 1) {
rowTop -= rowSpacing;
}
});
const rightContentHeight = summaryTop - rowTop;
const thumbX = marginX;
const thumbTop = summaryTop;
if (entry.thumbnailUrl) {
const thumbnail = await loadThumbnail(entry.thumbnailUrl);
if (thumbnail?.image) {
page.drawRectangle({
x: thumbX,
y: thumbTop - thumbnailHeight,
width: thumbnailWidth,
height: thumbnailHeight,
color: colorPalette.boxBackground,
borderColor: colorPalette.boxBorder,
borderWidth: 1,
});
drawThumbnailImage(page, thumbnail.image, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
} else {
drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
}
} else {
drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
}
const summarySectionHeight = Math.max(thumbnailHeight, rightContentHeight);
return summaryTop - summarySectionHeight - 32;
};
@@ -0,0 +1,55 @@
import { PDFFont, PDFPage, PDFImage } from 'pdf-lib';
import { colorPalette } from '../utils/pdfPalette';
export const drawThumbnailPlaceholder = (
page: PDFPage,
fontBold: PDFFont,
x: number,
top: number,
width: number,
height: number
) => {
page.drawRectangle({
x,
y: top - height,
width,
height,
color: colorPalette.boxBackground,
borderColor: colorPalette.boxBorder,
borderWidth: 1,
});
const label = 'PDF';
const labelSize = 22;
const labelWidth = fontBold.widthOfTextAtSize(label, labelSize);
const labelX = x + (width - labelWidth) / 2;
const labelY = top - height / 2 - labelSize / 2;
page.drawText(label, {
x: labelX,
y: labelY,
size: labelSize,
font: fontBold,
color: colorPalette.textMuted,
});
};
export const drawThumbnailImage = (
page: PDFPage,
image: PDFImage,
x: number,
top: number,
width: number,
height: number
) => {
const scaled = image.scaleToFit(width - 16, height - 16);
const offsetX = x + (width - scaled.width) / 2;
const offsetY = top - (height - scaled.height) / 2 - scaled.height;
page.drawImage(image, {
x: offsetX,
y: offsetY,
width: scaled.width,
height: scaled.height,
});
};
@@ -0,0 +1,139 @@
import { PDFDocument, PDFPage, StandardFonts } from 'pdf-lib';
import type { TFunction } from 'i18next';
import { SignatureValidationReportEntry } from '../../../types/validateSignature';
import { REPORT_PDF_FILENAME } from './utils/signatureUtils';
import { colorPalette } from './utils/pdfPalette';
import { startReportPage, createThumbnailLoader } from './utils/pdfPageHelpers';
import { deriveEntryStatus } from './utils/reportStatus';
import { drawCenteredMessage } from './outputtedPDFSections/CenteredMessageSection';
import { drawSummarySection } from './outputtedPDFSections/SummarySection';
import { drawSignatureSection } from './outputtedPDFSections/SignatureSection';
const PAGE_WIDTH = 612;
const PAGE_HEIGHT = 792;
const MARGIN_X = 52;
const MARGIN_Y = 22;
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN_X * 2;
const COLUMN_GAP = 18;
const drawDivider = (page: PDFPage, marginX: number, contentWidth: number, y: number) => {
page.drawLine({
start: { x: marginX, y },
end: { x: marginX + contentWidth, y },
thickness: 1,
color: colorPalette.boxBorder,
});
};
export const createReportPdf = async (
entries: SignatureValidationReportEntry[],
t: TFunction<'translation'>
): Promise<File> => {
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
const loadThumbnail = createThumbnailLoader(doc);
for (const entry of entries) {
const { text: statusText, color: statusColor } = deriveEntryStatus(entry, t);
let { page, cursorY } = startReportPage({
doc,
font,
fontBold,
marginX: MARGIN_X,
marginY: MARGIN_Y,
contentWidth: CONTENT_WIDTH,
pageWidth: PAGE_WIDTH,
pageHeight: PAGE_HEIGHT,
title: entry.fileName,
isContinuation: false,
t,
});
cursorY = await drawSummarySection({
page,
cursorY,
entry,
font,
fontBold,
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
columnGap: COLUMN_GAP,
statusText,
statusColor,
loadThumbnail,
t,
});
cursorY -= 12;
drawDivider(page, MARGIN_X, CONTENT_WIDTH, cursorY);
cursorY -= 16;
if (entry.error) {
cursorY = drawCenteredMessage({
page,
font,
fontBold,
text: t('validateSignature.status.invalid', 'Invalid'),
description: entry.error,
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
cursorY,
badgeColor: colorPalette.danger,
});
continue;
}
if (entry.signatures.length === 0) {
cursorY = drawCenteredMessage({
page,
font,
fontBold,
text: t('validateSignature.noSignaturesShort', 'No signatures'),
description: t('validateSignature.noSignatures', 'No digital signatures found in this document'),
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
cursorY,
badgeColor: colorPalette.neutral,
});
continue;
}
for (let i = 0; i < entry.signatures.length; i += 1) {
// After the first signature, start a new page per signature
if (i > 0) {
({ page, cursorY } = startReportPage({
doc,
font,
fontBold,
marginX: MARGIN_X,
marginY: MARGIN_Y,
contentWidth: CONTENT_WIDTH,
pageWidth: PAGE_WIDTH,
pageHeight: PAGE_HEIGHT,
title: entry.fileName,
isContinuation: true,
t,
}));
}
cursorY = drawSignatureSection({
page,
cursorY,
signature: entry.signatures[i],
index: i,
marginX: MARGIN_X,
contentWidth: CONTENT_WIDTH,
columnGap: COLUMN_GAP,
font,
fontBold,
t,
});
}
}
const pdfBytes = await doc.save();
const copy = pdfBytes.slice();
return new File([copy.buffer], REPORT_PDF_FILENAME, { type: 'application/pdf' });
};
@@ -0,0 +1,231 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../../services/apiClient';
import { useFileContext } from '../../../contexts/file/fileHooks';
import { ToolOperationHook } from '../shared/useToolOperation';
import type { StirlingFile } from '../../../types/fileContext';
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import {
SignatureValidationBackendResult,
SignatureValidationFileResult,
SignatureValidationReportEntry,
} from '../../../types/validateSignature';
import { ValidateSignatureParameters } from './useValidateSignatureParameters';
import { buildReportEntries } from './utils/signatureReportBuilder';
import { createReportPdf } from './signatureReportPdf';
import { createCsvFile as buildCsvFile } from './utils/signatureCsv';
import { normalizeBackendResult, RESULT_JSON_FILENAME } from './utils/signatureUtils';
export interface ValidateSignatureOperationHook extends ToolOperationHook<ValidateSignatureParameters> {
results: SignatureValidationReportEntry[];
}
export const useValidateSignatureOperation = (): ValidateSignatureOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [results, setResults] = useState<SignatureValidationReportEntry[]>([]);
const cancelRequested = useRef(false);
const previousUrl = useRef<string | null>(null);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
cancelRequested.current = false;
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatus('');
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const executeOperation = useCallback(
async (params: ValidateSignatureParameters, selectedFiles: StirlingFile[]) => {
if (selectedFiles.length === 0) {
setErrorMessage(t('noFileSelected', 'No files selected'));
return;
}
cancelRequested.current = false;
setIsLoading(true);
setStatus(t('validateSignature.processing', 'Validating signatures...'));
setErrorMessage(null);
setResults([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
try {
const aggregated: SignatureValidationFileResult[] = [];
for (const file of selectedFiles) {
if (cancelRequested.current) {
break;
}
const formData = new FormData();
formData.append('fileInput', file);
if (params.certFile) {
formData.append('certFile', params.certFile);
}
try {
const response = await apiClient.post('/api/v1/security/validate-signature', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const data = Array.isArray(response.data)
? (response.data as SignatureValidationBackendResult[])
: [];
const signatures = data.map((item, index) => normalizeBackendResult(item, file, index));
aggregated.push({
fileId: file.fileId,
fileName: file.name,
signatures,
error: null,
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
});
} catch (error) {
aggregated.push({
fileId: file.fileId,
fileName: file.name,
signatures: [],
error: extractErrorMessage(error),
fileSize: file.size ?? null,
lastModified: file.lastModified ?? null,
});
}
}
if (!cancelRequested.current) {
const summaryTimestamp = Date.now();
const enrichedEntries = buildReportEntries({
results: aggregated,
selectors,
generatedAt: summaryTimestamp,
t,
});
setResults(enrichedEntries);
if (enrichedEntries.length > 0) {
const json = JSON.stringify(enrichedEntries, null, 2);
const resultFile = new File([json], RESULT_JSON_FILENAME, { type: 'application/json' });
const csvFile = buildCsvFile(enrichedEntries);
setFiles([resultFile, csvFile]);
(async () => {
try {
const pdfFile = await createReportPdf(enrichedEntries, t);
setFiles((prev) => [pdfFile, ...prev.filter((f) => !f.name.toLowerCase().endsWith('.pdf'))]);
setDownloadFilename(pdfFile.name);
cleanupDownloadUrl();
const blobUrl = URL.createObjectURL(pdfFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
} catch (err) {
console.warn('[validateSignature] PDF report generation failed', err);
setErrorMessage((prev) =>
prev ??
t(
'validateSignature.error.reportGeneration',
'Could not generate the PDF report. JSON and CSV are available.'
)
);
}
})();
}
const anyError = aggregated.some((item) => item.error);
const anySuccess = aggregated.some((item) => item.signatures.length > 0);
if (anyError && !anySuccess) {
setErrorMessage(t('validateSignature.error.allFailed', 'Unable to validate the selected files.'));
} else if (anyError) {
setErrorMessage(t('validateSignature.error.partial', 'Some files could not be validated.'));
}
setStatus(t('validateSignature.status.complete', 'Validation complete'));
}
} catch (e) {
console.error('[validateSignature] unexpected failure', e);
setErrorMessage(t('validateSignature.error.unexpected', 'Unexpected error during validation.'));
} finally {
setIsLoading(false);
}
},
[cleanupDownloadUrl, selectors, t]
);
const cancelOperation = useCallback(() => {
if (isLoading) {
cancelRequested.current = true;
setIsLoading(false);
setStatus(t('operationCancelled', 'Operation cancelled'));
}
}, [isLoading, t]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
};
}, [cleanupDownloadUrl]);
return useMemo<ValidateSignatureOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
results,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
results,
status,
]
);
};
@@ -0,0 +1,18 @@
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface ValidateSignatureParameters {
certFile: File | null;
}
export const defaultParameters: ValidateSignatureParameters = {
certFile: null,
};
export type ValidateSignatureParametersHook = BaseParametersHook<ValidateSignatureParameters>;
export const useValidateSignatureParameters = (): ValidateSignatureParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'validate-signature',
});
};
@@ -0,0 +1,101 @@
import { PDFDocument, PDFFont, PDFImage } from 'pdf-lib';
import type { TFunction } from 'i18next';
import { colorPalette } from './pdfPalette';
interface StartPageParams {
doc: PDFDocument;
font: PDFFont;
fontBold: PDFFont;
marginX: number;
marginY: number;
contentWidth: number;
pageWidth: number;
pageHeight: number;
title: string;
isContinuation: boolean;
t: TFunction<'translation'>;
}
export const startReportPage = ({
doc,
font,
fontBold,
marginX,
marginY,
pageWidth,
pageHeight,
title,
isContinuation,
t,
}: StartPageParams) => {
const page = doc.addPage([pageWidth, pageHeight]);
let cursorY = pageHeight - marginY;
if (isContinuation) {
const heading = `${title} - ${t('validateSignature.report.continued', 'Continued')}`;
page.drawText(heading, {
x: marginX,
y: cursorY - 18,
size: 12,
font: fontBold,
color: colorPalette.textMuted,
});
cursorY -= 36;
}
const pageNumber = doc.getPageCount();
page.drawText(`${t('validateSignature.report.page', 'Page')} ${pageNumber}`, {
x: pageWidth - marginX - 80,
y: marginY / 2,
size: 9,
font,
color: colorPalette.textMuted,
});
page.drawText(t('validateSignature.report.footer', 'Validated via Stirling PDF'), {
x: marginX,
y: marginY / 2,
size: 9,
font,
color: colorPalette.textMuted,
});
return { page, cursorY };
};
export const createThumbnailLoader = (doc: PDFDocument) => {
const cache = new Map<string, { image: PDFImage } | null>();
return async (url: string) => {
if (cache.has(url)) {
return cache.get(url) ?? null;
}
try {
const response = await fetch(url);
const bytes = new Uint8Array(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || '';
let image: PDFImage;
if (contentType.includes('png')) {
image = await doc.embedPng(bytes);
} else if (contentType.includes('jpeg') || contentType.includes('jpg')) {
image = await doc.embedJpg(bytes);
} else {
try {
image = await doc.embedPng(bytes);
} catch {
image = await doc.embedJpg(bytes);
}
}
const result = { image };
cache.set(url, result);
return result;
} catch (error) {
console.warn('[validateSignature] Failed to load thumbnail', error);
cache.set(url, null);
return null;
}
};
};

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