Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9f4be39f | ||
|
|
5354f08766 | ||
|
|
a8573c99b7 | ||
|
|
f6a7b983a0 | ||
|
|
949a16e6eb | ||
|
|
43887c8179 | ||
|
|
bcd7762594 | ||
|
|
28e45917a2 | ||
|
|
af57ae02dd | ||
|
|
3cebcc70af | ||
|
|
b54beaa66b | ||
|
|
e542f4bc61 | ||
|
|
a339f71116 |
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## 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
|
||||
|
||||
+129
-43
@@ -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);
|
||||
|
||||
+19
-4
@@ -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
|
||||
|
||||
+814
-94
@@ -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
|
||||
|
||||
+46
-100
@@ -2,20 +2,20 @@ package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateExpiredException;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import javax.security.auth.x500.X500Principal;
|
||||
import java.util.Date;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Tests for the CertificateValidationService using mocked certificates. */
|
||||
class CertificateValidationServiceTest {
|
||||
@@ -26,121 +26,67 @@ class CertificateValidationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
validationService = new CertificateValidationService();
|
||||
// Create mock ApplicationProperties with default validation settings
|
||||
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
|
||||
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
|
||||
ApplicationProperties.Security.Validation validation =
|
||||
mock(ApplicationProperties.Security.Validation.class);
|
||||
ApplicationProperties.Security.Validation.Trust trust =
|
||||
mock(ApplicationProperties.Security.Validation.Trust.class);
|
||||
ApplicationProperties.Security.Validation.Revocation revocation =
|
||||
mock(ApplicationProperties.Security.Validation.Revocation.class);
|
||||
|
||||
when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
when(security.getValidation()).thenReturn(validation);
|
||||
when(validation.getTrust()).thenReturn(trust);
|
||||
when(validation.getRevocation()).thenReturn(revocation);
|
||||
when(validation.isAllowAIA()).thenReturn(false);
|
||||
when(trust.isServerAsAnchor()).thenReturn(false);
|
||||
when(trust.isUseSystemTrust()).thenReturn(false);
|
||||
when(trust.isUseMozillaBundle()).thenReturn(false);
|
||||
when(trust.isUseAATL()).thenReturn(false);
|
||||
when(trust.isUseEUTL()).thenReturn(false);
|
||||
when(revocation.getMode()).thenReturn("none");
|
||||
when(revocation.isHardFail()).thenReturn(false);
|
||||
|
||||
validationService = new CertificateValidationService(null, applicationProperties);
|
||||
|
||||
// Create mock certificates
|
||||
validCertificate = mock(X509Certificate.class);
|
||||
expiredCertificate = mock(X509Certificate.class);
|
||||
|
||||
// Set up behaviors for valid certificate
|
||||
doNothing().when(validCertificate).checkValidity(); // No exception means valid
|
||||
// Set up behaviors for valid certificate (both overloads)
|
||||
doNothing().when(validCertificate).checkValidity();
|
||||
doNothing().when(validCertificate).checkValidity(any(Date.class));
|
||||
|
||||
// Set up behaviors for expired certificate
|
||||
// Set up behaviors for expired certificate (both overloads)
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity();
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity(any(Date.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsRevoked_ValidCertificate() {
|
||||
void testIsOutsideValidityPeriod_ValidCertificate() {
|
||||
// When certificate is valid (not expired)
|
||||
boolean result = validationService.isRevoked(validCertificate);
|
||||
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
|
||||
|
||||
// Then it should not be considered revoked
|
||||
assertFalse(result, "Valid certificate should not be considered revoked");
|
||||
// Then it should not be outside validity period
|
||||
assertFalse(result, "Valid certificate should not be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsRevoked_ExpiredCertificate() {
|
||||
void testIsOutsideValidityPeriod_ExpiredCertificate() {
|
||||
// When certificate is expired
|
||||
boolean result = validationService.isRevoked(expiredCertificate);
|
||||
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
|
||||
|
||||
// Then it should be considered revoked
|
||||
assertTrue(result, "Expired certificate should be considered revoked");
|
||||
// Then it should be outside validity period
|
||||
assertTrue(result, "Expired certificate should be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_Match() {
|
||||
// Create certificates with matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
|
||||
// Mock the issuer of the signed certificate to match the subject of the issuing certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate with matching issuer and subject should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_NoMatch() {
|
||||
// Create certificates with non-matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
X500Principal differentPrincipal = new X500Principal("CN=Different Name");
|
||||
|
||||
// Mock the issuer of the signed certificate to NOT match the subject of the issuing
|
||||
// certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate with non-matching issuer and subject should not validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Success() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, don't throw exception
|
||||
doNothing().when(signedCert).verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate chain with proper signing should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Failure() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, throw exception
|
||||
// Need to use a specific exception that verify() can throw
|
||||
doThrow(new java.security.SignatureException("Verification failed"))
|
||||
.when(signedCert)
|
||||
.verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate chain with failed signing should not validate");
|
||||
}
|
||||
// Note: Full integration tests for buildAndValidatePath() would require
|
||||
// real certificate chains and trust anchors. These would be better as
|
||||
// integration tests using actual signed PDFs from the test-signed-pdfs directory.
|
||||
}
|
||||
|
||||
+172
-35
@@ -36,6 +36,12 @@ import stirling.software.proprietary.security.model.exception.BackupNotFoundExce
|
||||
@Service
|
||||
public class DatabaseService implements DatabaseServiceInterface {
|
||||
|
||||
private enum Dialect {
|
||||
H2,
|
||||
POSTGRES,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
public static final String BACKUP_PREFIX = "backup_";
|
||||
public static final String SQL_SUFFIX = ".sql";
|
||||
private final Path BACKUP_DIR;
|
||||
@@ -45,10 +51,26 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
|
||||
public DatabaseService(
|
||||
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
|
||||
this.BACKUP_DIR =
|
||||
Paths.get(InstallationPathConfig.getConfigPath(), "db", "backup").normalize();
|
||||
this.datasourceProps = datasourceProps;
|
||||
this.dataSource = dataSource;
|
||||
this.BACKUP_DIR = resolveBackupDir(datasourceProps);
|
||||
}
|
||||
|
||||
private Path resolveBackupDir(ApplicationProperties.Datasource ds) {
|
||||
String base = InstallationPathConfig.getConfigPath();
|
||||
String sub = "unknown";
|
||||
boolean custom = ds.isEnableCustomDatabase();
|
||||
String type = ds.getType() == null ? "" : ds.getType().toUpperCase();
|
||||
String url =
|
||||
ds.getCustomDatabaseUrl() == null ? "" : ds.getCustomDatabaseUrl().toLowerCase();
|
||||
if (!custom) {
|
||||
sub = "h2";
|
||||
} else if ("H2".equals(type) && url.contains("h2")) {
|
||||
sub = "h2";
|
||||
} else if ("POSTGRESQL".equals(type) && url.contains("postgres")) {
|
||||
sub = "postgres";
|
||||
}
|
||||
return Paths.get(base, "db", "backup", sub).normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,38 +99,33 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
public List<FileInfo> getBackupList() {
|
||||
List<FileInfo> backupFiles = new ArrayList<>();
|
||||
|
||||
if (isH2Database()) {
|
||||
createBackupDirectory();
|
||||
createBackupDirectory();
|
||||
|
||||
try (DirectoryStream<Path> stream =
|
||||
Files.newDirectoryStream(
|
||||
BACKUP_DIR,
|
||||
path ->
|
||||
path.getFileName().toString().startsWith(BACKUP_PREFIX)
|
||||
&& path.getFileName()
|
||||
.toString()
|
||||
.endsWith(SQL_SUFFIX))) {
|
||||
for (Path entry : stream) {
|
||||
BasicFileAttributes attrs =
|
||||
Files.readAttributes(entry, BasicFileAttributes.class);
|
||||
LocalDateTime modificationDate =
|
||||
LocalDateTime.ofInstant(
|
||||
attrs.lastModifiedTime().toInstant(), ZoneId.systemDefault());
|
||||
LocalDateTime creationDate =
|
||||
LocalDateTime.ofInstant(
|
||||
attrs.creationTime().toInstant(), ZoneId.systemDefault());
|
||||
long fileSize = attrs.size();
|
||||
backupFiles.add(
|
||||
new FileInfo(
|
||||
entry.getFileName().toString(),
|
||||
entry.toString(),
|
||||
modificationDate,
|
||||
fileSize,
|
||||
creationDate));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading backup directory: {}", e.getMessage(), e);
|
||||
try (DirectoryStream<Path> stream =
|
||||
Files.newDirectoryStream(
|
||||
BACKUP_DIR,
|
||||
path ->
|
||||
path.getFileName().toString().startsWith(BACKUP_PREFIX)
|
||||
&& path.getFileName().toString().endsWith(SQL_SUFFIX))) {
|
||||
for (Path entry : stream) {
|
||||
BasicFileAttributes attrs = Files.readAttributes(entry, BasicFileAttributes.class);
|
||||
LocalDateTime modificationDate =
|
||||
LocalDateTime.ofInstant(
|
||||
attrs.lastModifiedTime().toInstant(), ZoneId.systemDefault());
|
||||
LocalDateTime creationDate =
|
||||
LocalDateTime.ofInstant(
|
||||
attrs.creationTime().toInstant(), ZoneId.systemDefault());
|
||||
long fileSize = attrs.size();
|
||||
backupFiles.add(
|
||||
new FileInfo(
|
||||
entry.getFileName().toString(),
|
||||
entry.toString(),
|
||||
modificationDate,
|
||||
fileSize,
|
||||
creationDate));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error reading backup directory: {}", e.getMessage(), e);
|
||||
}
|
||||
|
||||
return backupFiles;
|
||||
@@ -195,6 +212,56 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
}
|
||||
|
||||
log.info("Database export completed: {}", insertOutputFilePath);
|
||||
} else if (isPostgresDatabase()) {
|
||||
// Use pg_dump to export PostgreSQL database
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("pg_dump");
|
||||
if (datasourceProps.getHostName() != null) {
|
||||
command.add("-h");
|
||||
command.add(datasourceProps.getHostName());
|
||||
}
|
||||
if (datasourceProps.getPort() != null) {
|
||||
command.add("-p");
|
||||
command.add(String.valueOf(datasourceProps.getPort()));
|
||||
}
|
||||
if (datasourceProps.getUsername() != null) {
|
||||
command.add("-U");
|
||||
command.add(datasourceProps.getUsername());
|
||||
}
|
||||
command.add("-F");
|
||||
command.add("p"); // plain SQL
|
||||
command.add("--inserts");
|
||||
command.add("-f");
|
||||
command.add(insertOutputFilePath.toString());
|
||||
// Database name
|
||||
if (datasourceProps.getName() != null) {
|
||||
command.add(datasourceProps.getName());
|
||||
}
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
// Set password via env var to avoid prompt
|
||||
if (datasourceProps.getPassword() != null) {
|
||||
pb.environment().put("PGPASSWORD", datasourceProps.getPassword());
|
||||
}
|
||||
pb.redirectErrorStream(true);
|
||||
try {
|
||||
Process process = pb.start();
|
||||
int exit = process.waitFor();
|
||||
if (exit != 0) {
|
||||
log.error(
|
||||
"pg_dump exited with code {} while exporting to {}",
|
||||
exit,
|
||||
insertOutputFilePath);
|
||||
} else {
|
||||
log.info("Database export completed: {}", insertOutputFilePath);
|
||||
}
|
||||
} catch (IOException | InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Error during PostgreSQL database export: {}", e.getMessage(), e);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
"Database export not implemented for this driver. Only H2 and PostgreSQL are supported.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,9 +307,7 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
private boolean isH2Database() {
|
||||
boolean isTypeH2 =
|
||||
datasourceProps.getType().equalsIgnoreCase(ApplicationProperties.Driver.H2.name());
|
||||
boolean isDBUrlH2 =
|
||||
datasourceProps.getCustomDatabaseUrl().contains("h2")
|
||||
|| datasourceProps.getCustomDatabaseUrl().contains("H2");
|
||||
boolean isDBUrlH2 = datasourceProps.getCustomDatabaseUrl().toLowerCase().contains("h2");
|
||||
boolean isCustomDatabase = datasourceProps.isEnableCustomDatabase();
|
||||
|
||||
if (isCustomDatabase) {
|
||||
@@ -267,6 +332,35 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
return !isCustomDatabase || isH2;
|
||||
}
|
||||
|
||||
private boolean isPostgresDatabase() {
|
||||
boolean isTypePg =
|
||||
datasourceProps
|
||||
.getType()
|
||||
.equalsIgnoreCase(ApplicationProperties.Driver.POSTGRESQL.name());
|
||||
String url =
|
||||
datasourceProps.getCustomDatabaseUrl() == null
|
||||
? ""
|
||||
: datasourceProps.getCustomDatabaseUrl();
|
||||
boolean isDBUrlPg = url.toLowerCase().contains("postgres");
|
||||
boolean isCustomDatabase = datasourceProps.isEnableCustomDatabase();
|
||||
|
||||
if (isCustomDatabase) {
|
||||
if (isTypePg && !isDBUrlPg) {
|
||||
log.warn(
|
||||
"Datasource type is POSTGRESQL, but the URL does not contain 'postgres'. Please check your configuration.");
|
||||
throw new IllegalStateException(
|
||||
"Datasource type is POSTGRESQL, but the URL does not contain 'postgres'. Please check your configuration.");
|
||||
} else if (!isTypePg && isDBUrlPg) {
|
||||
log.warn(
|
||||
"Datasource URL contains 'postgres', but the type is not POSTGRESQL. Please check your configuration.");
|
||||
throw new IllegalStateException(
|
||||
"Datasource URL contains 'postgres', but the type is not POSTGRESQL. Please check your configuration.");
|
||||
}
|
||||
}
|
||||
boolean isPg = isTypePg && isDBUrlPg;
|
||||
return isCustomDatabase && isPg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a backup file.
|
||||
*
|
||||
@@ -314,6 +408,49 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
} catch (ScriptException e) {
|
||||
log.error("Error: File {} not found", scriptPath.toString(), e);
|
||||
}
|
||||
} else if (isPostgresDatabase()) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("psql");
|
||||
|
||||
if (datasourceProps.getHostName() != null) {
|
||||
command.add("-h");
|
||||
command.add(datasourceProps.getHostName());
|
||||
}
|
||||
if (datasourceProps.getPort() != null) {
|
||||
command.add("-p");
|
||||
command.add(String.valueOf(datasourceProps.getPort()));
|
||||
}
|
||||
if (datasourceProps.getUsername() != null) {
|
||||
command.add("-U");
|
||||
command.add(datasourceProps.getUsername());
|
||||
}
|
||||
if (datasourceProps.getName() != null) {
|
||||
command.add("-d");
|
||||
command.add(datasourceProps.getName());
|
||||
}
|
||||
command.add("-f");
|
||||
command.add(scriptPath.toString());
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
if (datasourceProps.getPassword() != null) {
|
||||
pb.environment().put("PGPASSWORD", datasourceProps.getPassword());
|
||||
}
|
||||
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
try {
|
||||
Process process = pb.start();
|
||||
int exit = process.waitFor();
|
||||
if (exit != 0) {
|
||||
log.error("psql exited with code {} while importing from {}", exit, scriptPath);
|
||||
} else {
|
||||
log.info("Database import completed: {}", scriptPath);
|
||||
}
|
||||
} catch (IOException | InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("Error during PostgreSQL database import: {}", e.getMessage(), e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Database import completed: {}", scriptPath);
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.sql.SQLException;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class DatabaseServiceTest {
|
||||
|
||||
private Path tempBaseDir;
|
||||
private MockedStatic<InstallationPathConfig> mockedInstallationConfig;
|
||||
|
||||
@Mock private DataSource dataSource;
|
||||
|
||||
private ApplicationProperties.Datasource h2DatasourceProps;
|
||||
private ApplicationProperties.Datasource pgDatasourceProps;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws IOException {
|
||||
tempBaseDir = Files.createTempDirectory("dbservice-test-");
|
||||
mockedInstallationConfig = Mockito.mockStatic(InstallationPathConfig.class);
|
||||
mockedInstallationConfig
|
||||
.when(InstallationPathConfig::getConfigPath)
|
||||
.thenReturn(tempBaseDir.toString());
|
||||
|
||||
// H2 default (custom disabled)
|
||||
h2DatasourceProps = new ApplicationProperties.Datasource();
|
||||
h2DatasourceProps.setEnableCustomDatabase(false);
|
||||
h2DatasourceProps.setType(ApplicationProperties.Driver.H2.name());
|
||||
h2DatasourceProps.setCustomDatabaseUrl("jdbc:h2:file:./data");
|
||||
|
||||
// PostgreSQL custom enabled
|
||||
pgDatasourceProps = new ApplicationProperties.Datasource();
|
||||
pgDatasourceProps.setEnableCustomDatabase(true);
|
||||
pgDatasourceProps.setType(ApplicationProperties.Driver.POSTGRESQL.name());
|
||||
pgDatasourceProps.setCustomDatabaseUrl("jdbc:postgresql://localhost:5432/mydb");
|
||||
pgDatasourceProps.setHostName("localhost");
|
||||
pgDatasourceProps.setPort(5432);
|
||||
pgDatasourceProps.setName("mydb");
|
||||
pgDatasourceProps.setUsername("user");
|
||||
pgDatasourceProps.setPassword("pass");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws IOException {
|
||||
if (mockedInstallationConfig != null) {
|
||||
mockedInstallationConfig.close();
|
||||
}
|
||||
if (tempBaseDir != null) {
|
||||
// Cleanup temp files
|
||||
Files.walk(tempBaseDir)
|
||||
.sorted((a, b) -> b.getNameCount() - a.getNameCount())
|
||||
.forEach(
|
||||
p -> {
|
||||
try {
|
||||
Files.deleteIfExists(p);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void backupDirectoryResolution_h2() {
|
||||
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
|
||||
Path path = service.getBackupFilePath("backup_test.sql");
|
||||
assertTrue(
|
||||
path.toString().contains("/db/backup/h2/")
|
||||
|| path.toString().contains("\\db\\backup\\h2\\"),
|
||||
"Backup directory should resolve to h2 subfolder");
|
||||
assertTrue(Files.exists(path.getParent()), "Backup directory should be created");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backupDirectoryResolution_postgres() {
|
||||
DatabaseService service = new DatabaseService(pgDatasourceProps, dataSource);
|
||||
Path path = service.getBackupFilePath("backup_test.sql");
|
||||
assertTrue(
|
||||
path.toString().contains("/db/backup/postgres/")
|
||||
|| path.toString().contains("\\db\\backup\\postgres\\"),
|
||||
"Backup directory should resolve to postgres subfolder");
|
||||
assertTrue(Files.exists(path.getParent()), "Backup directory should be created");
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasBackup_false_then_true() throws IOException {
|
||||
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
|
||||
// Initially no backups
|
||||
assertFalse(service.hasBackup());
|
||||
|
||||
// Create a valid backup file
|
||||
Path backup = service.getBackupFilePath("backup_202001010000.sql");
|
||||
Files.createDirectories(backup.getParent());
|
||||
Files.createFile(backup);
|
||||
|
||||
assertTrue(service.hasBackup());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupFilePath_preventsPathTraversal() {
|
||||
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
|
||||
assertThrows(SecurityException.class, () -> service.getBackupFilePath("../evil.sql"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void importDatabaseFromUI_copiesAndDeletesTemp_evenIfExecutionFails() throws Exception {
|
||||
// Arrange H2 branch; mock connection acquisition to throw so that executeDatabaseScript
|
||||
// logs error
|
||||
when(dataSource.getConnection()).thenThrow(new SQLException("No DB in unit test"));
|
||||
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
|
||||
|
||||
Path tempSql = Files.createTempFile("temp-import-", ".sql");
|
||||
Files.writeString(tempSql, "-- test content");
|
||||
|
||||
// Act
|
||||
boolean result = service.importDatabaseFromUI(tempSql);
|
||||
|
||||
// Assert
|
||||
assertTrue(result, "Method should return true regardless of execution outcome");
|
||||
assertFalse(
|
||||
Files.exists(tempSql), "Temporary uploaded file should be deleted after import");
|
||||
|
||||
// The copied file should exist in backup directory with user_ prefix
|
||||
Path parent = Files.createDirectories(service.getBackupFilePath("dummy.sql").getParent());
|
||||
// find a file starting with backup_user_
|
||||
boolean found =
|
||||
Files.list(parent)
|
||||
.anyMatch(
|
||||
p ->
|
||||
p.getFileName().toString().startsWith("backup_user_")
|
||||
&& p.getFileName().toString().endsWith(".sql"));
|
||||
assertTrue(found, "A user_ prefixed backup copy should be created");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupList_sortsByModificationDateDesc() throws Exception {
|
||||
DatabaseService service = new DatabaseService(h2DatasourceProps, dataSource);
|
||||
Path dir = service.getBackupFilePath("backup_dummy.sql").getParent();
|
||||
Files.createDirectories(dir);
|
||||
|
||||
Path older = dir.resolve("backup_202001010000.sql");
|
||||
Path newer = dir.resolve("backup_202001020000.sql");
|
||||
Files.writeString(older, "-- old");
|
||||
Files.writeString(newer, "-- new");
|
||||
Files.setLastModifiedTime(older, FileTime.fromMillis(1_000L));
|
||||
Files.setLastModifiedTime(newer, FileTime.fromMillis(2_000L));
|
||||
|
||||
var list = service.getBackupList();
|
||||
assertEquals(2, list.size());
|
||||
// Newest should be first after we sort in importDatabase, but getBackupList returns
|
||||
// unsorted.
|
||||
// We'll verify importDatabase picks the latest by modification time by creating a no-op
|
||||
// DataSource
|
||||
when(dataSource.getConnection()).thenThrow(new SQLException("No DB in unit test"));
|
||||
service.importDatabase();
|
||||
// If no exception thrown, method executed and chose a file; functional behavior verified by
|
||||
// no crash.
|
||||
}
|
||||
}
|
||||
Generated
+137
-136
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -73,6 +90,11 @@
|
||||
"moreOptions": "More Options",
|
||||
"editYourNewFiles": "Edit your new file(s)",
|
||||
"close": "Close",
|
||||
"openInViewer": "Open in Viewer",
|
||||
"confirmClose": "Confirm Close",
|
||||
"confirmCloseMessage": "Are you sure you want to close this file?",
|
||||
"confirmCloseCancel": "Cancel",
|
||||
"confirmCloseConfirm": "Close File",
|
||||
"fileSelected": "Selected: {{filename}}",
|
||||
"chooseFile": "Choose File",
|
||||
"filesSelected": "{{count}} files selected",
|
||||
@@ -598,11 +620,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 +2567,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 +2585,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,9 +40,9 @@ const LoadingFallback = () => (
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<PreferencesProvider>
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
@@ -62,9 +62,9 @@ export default function App() {
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
Text, Center, Box, LoadingOverlay, Stack
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '../../contexts/FileContext';
|
||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import AddFileCard from './AddFileCard';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
import { alert } from '../toast';
|
||||
import { downloadBlob } from '../../utils/downloadUtils';
|
||||
import { useFileEditorRightRailButtons } from './fileEditorRightRailButtons';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -37,11 +37,15 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions } = useFileActions();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: fileContextActions } = useFileContext();
|
||||
const { clearAllFileErrors } = fileContextActions;
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const totalItems = state.files.ids.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
@@ -62,25 +66,12 @@ const FileEditor = ({
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
|
||||
isExtracting: boolean;
|
||||
currentFile: string;
|
||||
progress: number;
|
||||
extractedCount: number;
|
||||
totalFiles: number;
|
||||
}>({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
@@ -91,107 +82,63 @@ const FileEditor = ({
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
const handleSelectAllFiles = useCallback(() => {
|
||||
setSelectedFiles(state.files.ids);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on select all:', error);
|
||||
}
|
||||
}
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleDeselectAllFiles = useCallback(() => {
|
||||
setSelectedFiles([]);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on deselect:', error);
|
||||
}
|
||||
}
|
||||
}, [setSelectedFiles, clearAllFileErrors]);
|
||||
|
||||
const handleCloseSelectedFiles = useCallback(() => {
|
||||
if (selectedFileIds.length === 0) return;
|
||||
void removeFiles(selectedFileIds, false);
|
||||
setSelectedFiles([]);
|
||||
}, [selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll: handleSelectAllFiles,
|
||||
onDeselectAll: handleDeselectAllFiles,
|
||||
onCloseSelected: handleCloseSelectedFiles,
|
||||
});
|
||||
|
||||
// Process uploaded files using context
|
||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (file.type === 'application/pdf') {
|
||||
// Handle PDF files normally
|
||||
allExtractedFiles.push(file);
|
||||
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Handle ZIP files - only expand if they contain PDFs
|
||||
try {
|
||||
// Validate ZIP file first
|
||||
const validation = await zipFileService.validateZipFile(file);
|
||||
|
||||
if (validation.isValid && validation.containsPDFs) {
|
||||
// ZIP contains PDFs - extract them
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: file.name,
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: validation.fileCount
|
||||
});
|
||||
|
||||
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: progress.currentFile,
|
||||
progress: progress.progress,
|
||||
extractedCount: progress.extractedCount,
|
||||
totalFiles: progress.totalFiles
|
||||
});
|
||||
});
|
||||
|
||||
// Reset extraction progress
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
// ZIP doesn't contain PDFs or is invalid - treat as regular file
|
||||
allExtractedFiles.push(file);
|
||||
}
|
||||
} catch (zipError) {
|
||||
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
allExtractedFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
showError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context and select them automatically
|
||||
await addFiles(allExtractedFiles, { selectFiles: true });
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
if (uploadedFiles.length > 0) {
|
||||
// FileContext will automatically handle ZIP extraction based on user preferences
|
||||
// - Respects autoUnzip setting
|
||||
// - Respects autoUnzipFileLimit
|
||||
// - HTML ZIPs stay intact
|
||||
// - Non-ZIP files pass through unchanged
|
||||
await addFiles(uploadedFiles, { selectFiles: true });
|
||||
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
}, [addFiles]);
|
||||
}, [addFiles, showStatus, showError]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
@@ -320,7 +267,7 @@ const FileEditor = ({
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
await actions.addStirlingFileStubs(result.extractedStubs);
|
||||
await fileActions.addStirlingFileStubs(result.extractedStubs);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
@@ -350,7 +297,7 @@ const FileEditor = ({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
|
||||
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
@@ -394,7 +341,7 @@ const FileEditor = ({
|
||||
<Box p="md">
|
||||
|
||||
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
{activeStirlingFileStubs.length === 0 ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
@@ -402,43 +349,6 @@ const FileEditor = ({
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
{/* ZIP Extraction Progress */}
|
||||
{zipExtractionProgress.isExtracting && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.currentFile || 'Processing files...'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
|
||||
</Text>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(zipExtractionProgress.progress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-orange-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
@@ -19,6 +20,7 @@ import { useFileState } from '../../contexts/file/fileHooks';
|
||||
import { FileId } from '../../types/file';
|
||||
import { formatFileSize } from '../../utils/fileUtils';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu';
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +47,7 @@ const FileEditorThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onCloseFile,
|
||||
onViewFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
@@ -59,8 +62,9 @@ const FileEditorThumbnail = ({
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
@@ -154,46 +158,66 @@ const FileEditorThumbnail = ({
|
||||
};
|
||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||
|
||||
// Update dropdown width on resize
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
// Handle close with confirmation
|
||||
const handleCloseWithConfirmation = useCallback(() => {
|
||||
setShowCloseModal(true);
|
||||
}, []);
|
||||
|
||||
// Close the actions dropdown when hovering outside this file card (and its dropdown)
|
||||
useEffect(() => {
|
||||
if (!showActions) return;
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, onCloseFile]);
|
||||
|
||||
const isInsideCard = (target: EventTarget | null) => {
|
||||
const container = dragElementRef.current;
|
||||
if (!container) return false;
|
||||
return target instanceof Node && container.contains(target);
|
||||
};
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseModal(false);
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// On touch devices, close if the touch target is outside the card
|
||||
if (!isInsideCard(e.target)) {
|
||||
setShowActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
}, [showActions]);
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
id: 'view',
|
||||
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
||||
label: t('openInViewer', 'Open in Viewer'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
label: t('download', 'Download'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'unzip',
|
||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||
label: t('fileManager.unzip', 'Unzip'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (onUnzipFile) {
|
||||
onUnzipFile(file.id);
|
||||
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile,
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
||||
label: t('close', 'Close'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseWithConfirmation();
|
||||
},
|
||||
color: 'red',
|
||||
}
|
||||
], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
@@ -205,6 +229,11 @@ const FileEditorThumbnail = ({
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
if (!isSupported) return;
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
@@ -226,6 +255,9 @@ const FileEditorThumbnail = ({
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setShowHoverMenu(true)}
|
||||
onMouseLeave={() => setShowHoverMenu(false)}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
@@ -282,98 +314,9 @@ const FileEditorThumbnail = ({
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Download icon */}
|
||||
<Tooltip label={t('download', 'Download')}>
|
||||
<ActionIcon
|
||||
aria-label={t('download', 'Download')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions overlay */}
|
||||
{showActions && (
|
||||
<div
|
||||
className={styles.actionsOverlay}
|
||||
style={{ width: actionsWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => {
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
{isZipFile && onUnzipFile && (
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<UnarchiveIcon fontSize="small" />
|
||||
<span>{t('fileManager.unzip', 'Unzip')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
<span>{t('close', 'Close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
style={{
|
||||
@@ -464,6 +407,37 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu
|
||||
show={showHoverMenu || isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
/>
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
opened={showCloseModal}
|
||||
onClose={handleCancelClose}
|
||||
title={t('confirmClose', 'Confirm Close')}
|
||||
centered
|
||||
size="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseConfirm', 'Close File')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
|
||||
interface FileEditorRightRailButtonsParams {
|
||||
totalItems: number;
|
||||
selectedCount: number;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onCloseSelected: () => void;
|
||||
}
|
||||
|
||||
export function useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onCloseSelected,
|
||||
}: FileEditorRightRailButtonsParams) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'file-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.selectAll', 'Select All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
visible: totalItems > 0,
|
||||
onClick: onSelectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-deselect-all',
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.deselectAll', 'Deselect All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onDeselectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-close-selected',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onCloseSelected,
|
||||
},
|
||||
], [t, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{currentFile && thumbnail ? (
|
||||
<img
|
||||
className='ph-no-capture'
|
||||
src={thumbnail}
|
||||
alt={currentFile.name}
|
||||
style={{
|
||||
@@ -66,7 +67,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<Text className='ph-no-capture' size="sm" fw={500} truncate>
|
||||
{currentFile ? currentFile.name : 'No file selected'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '../../styles/zIndex';
|
||||
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -40,7 +41,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);
|
||||
@@ -127,6 +128,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
@@ -188,7 +190,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToRecents(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.restore', 'Restore')}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { GRID_CONSTANTS } from './constants';
|
||||
import { usePageDocument } from './hooks/usePageDocument';
|
||||
import { usePageEditorState } from './hooks/usePageEditorState';
|
||||
import { parseSelection } from "../../utils/bulkselection/parseSelection";
|
||||
import { usePageEditorRightRailButtons } from "./pageEditorRightRailButtons";
|
||||
|
||||
export interface PageEditorProps {
|
||||
onFunctionsReady?: (functions: PageEditorFunctions) => void;
|
||||
@@ -44,6 +46,7 @@ const PageEditor = ({
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
@@ -65,6 +68,12 @@ const PageEditor = ({
|
||||
togglePage, toggleSelectAll, animateReorder
|
||||
} = usePageEditorState();
|
||||
|
||||
const [csvInput, setCsvInput] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setCsvInput('');
|
||||
}, [activeFilesSignature]);
|
||||
|
||||
// Grid container ref for positioning split indicators
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -118,6 +127,8 @@ const PageEditor = ({
|
||||
|
||||
// Interface functions for parent component
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
const totalPages = displayDocument?.pages.length ?? 0;
|
||||
const selectedPageCount = selectedPageIds.length;
|
||||
|
||||
// Utility functions to convert between page IDs and page numbers
|
||||
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
|
||||
@@ -414,6 +425,12 @@ const PageEditor = ({
|
||||
setSelectedPageIds(pageIds);
|
||||
}, [getPageIdsFromNumbers, setSelectedPageIds]);
|
||||
|
||||
const updatePagesFromCSV = useCallback((override?: string) => {
|
||||
if (totalPages === 0) return;
|
||||
const normalized = parseSelection(override ?? csvInput, totalPages);
|
||||
handleSetSelectedPages(normalized);
|
||||
}, [csvInput, totalPages, handleSetSelectedPages]);
|
||||
|
||||
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
@@ -609,6 +626,23 @@ const PageEditor = ({
|
||||
setSelectionMode(false);
|
||||
}, [actions]);
|
||||
|
||||
usePageEditorRightRailButtons({
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument: displayDocument || undefined,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount: activeFileIds.length,
|
||||
closePdf,
|
||||
});
|
||||
|
||||
// Export preview function - defined after export functions to avoid circular dependency
|
||||
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||
|
||||
interface PageSelectByNumberButtonProps {
|
||||
disabled: boolean;
|
||||
totalPages: number;
|
||||
label: string;
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
updatePagesFromCSV: (override?: string) => void;
|
||||
}
|
||||
|
||||
export default function PageSelectByNumberButton({
|
||||
disabled,
|
||||
totalPages,
|
||||
label,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
}: PageSelectByNumberButtonProps) {
|
||||
return (
|
||||
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Text, Checkbox } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
@@ -12,6 +13,7 @@ import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import styles from './PageEditor.module.css';
|
||||
import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu';
|
||||
|
||||
|
||||
interface PageThumbnailProps {
|
||||
@@ -65,6 +67,8 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
@@ -270,8 +274,75 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
id: 'move-left',
|
||||
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Move Left',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
},
|
||||
disabled: index === 0
|
||||
},
|
||||
{
|
||||
id: 'move-right',
|
||||
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Move Right',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onReorderPages(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
},
|
||||
disabled: index === totalPages - 1
|
||||
},
|
||||
{
|
||||
id: 'rotate-left',
|
||||
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Rotate Left',
|
||||
onClick: handleRotateLeft,
|
||||
},
|
||||
{
|
||||
id: 'rotate-right',
|
||||
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Rotate Right',
|
||||
onClick: handleRotateRight,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
icon: <DeleteIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Delete Page',
|
||||
onClick: handleDelete,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: 'split',
|
||||
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Split After',
|
||||
onClick: handleSplit,
|
||||
hidden: index >= totalPages - 1,
|
||||
},
|
||||
{
|
||||
id: 'insert',
|
||||
icon: <AddIcon style={{ fontSize: 20 }} />,
|
||||
label: 'Insert File After',
|
||||
onClick: handleInsertFileAfter,
|
||||
}
|
||||
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
@@ -302,6 +373,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{
|
||||
@@ -413,128 +485,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
{page.pageNumber}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
<HoverActionMenu
|
||||
show={isHovered || isMobile}
|
||||
actions={hoverActions}
|
||||
position="inside"
|
||||
className={styles.pageHoverControls}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip label="Move Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
disabled={index === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
// Actually move the page left (swap with previous page)
|
||||
onReorderPages(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowBackIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Move Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
disabled={index === totalPages - 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.pageNumber);
|
||||
// Actually move the page right (swap with next page)
|
||||
onReorderPages(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 650);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowForwardIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleRotateLeft}
|
||||
>
|
||||
<RotateLeftIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleRotateRight}
|
||||
>
|
||||
<RotateRightIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete Page">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{index < totalPages - 1 && (
|
||||
<Tooltip label="Split After">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleSplit}
|
||||
>
|
||||
<ContentCutIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip label="Insert File After">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: 'var(--mantine-color-dimmed)' }}
|
||||
onClick={handleInsertFileAfter}
|
||||
>
|
||||
<AddIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
|
||||
// Stable signature for effects (prevents loops)
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
@@ -156,7 +156,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
|
||||
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import PageSelectByNumberButton from './PageSelectByNumberButton';
|
||||
|
||||
interface PageEditorRightRailButtonsParams {
|
||||
totalPages: number;
|
||||
selectedPageCount: number;
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
updatePagesFromCSV: (override?: string) => void;
|
||||
handleSelectAll: () => void;
|
||||
handleDeselectAll: () => void;
|
||||
handleDelete: () => void;
|
||||
onExportSelected: () => void;
|
||||
exportLoading: boolean;
|
||||
activeFileCount: number;
|
||||
closePdf: () => void;
|
||||
}
|
||||
|
||||
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) {
|
||||
const {
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
} = params;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const selectAllLabel = t('rightRail.selectAll', 'Select All');
|
||||
const deselectAllLabel = t('rightRail.deselectAll', 'Deselect All');
|
||||
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
|
||||
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
|
||||
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
|
||||
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'page-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: selectAllLabel,
|
||||
ariaLabel: selectAllLabel,
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
disabled: totalPages === 0 || selectedPageCount === totalPages,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleSelectAll,
|
||||
},
|
||||
{
|
||||
id: 'page-deselect-all',
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: deselectAllLabel,
|
||||
ariaLabel: deselectAllLabel,
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
disabled: selectedPageCount === 0,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleDeselectAll,
|
||||
},
|
||||
{
|
||||
id: 'page-select-by-number',
|
||||
tooltip: selectByNumberLabel,
|
||||
ariaLabel: selectByNumberLabel,
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
disabled: totalPages === 0,
|
||||
visible: totalPages > 0,
|
||||
render: ({ disabled }) => (
|
||||
<PageSelectByNumberButton
|
||||
disabled={disabled}
|
||||
totalPages={totalPages}
|
||||
label={selectByNumberLabel}
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
updatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'page-delete-selected',
|
||||
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: deleteSelectedLabel,
|
||||
ariaLabel: deleteSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 40,
|
||||
disabled: selectedPageCount === 0,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
{
|
||||
id: 'page-export-selected',
|
||||
icon: <LocalIcon icon="download" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: exportSelectedLabel,
|
||||
ariaLabel: exportSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 50,
|
||||
disabled: selectedPageCount === 0 || exportLoading,
|
||||
visible: totalPages > 0,
|
||||
onClick: onExportSelected,
|
||||
},
|
||||
{
|
||||
id: 'page-close-pdf',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: closePdfLabel,
|
||||
ariaLabel: closePdfLabel,
|
||||
section: 'top' as const,
|
||||
order: 60,
|
||||
disabled: activeFileCount === 0,
|
||||
visible: activeFileCount > 0,
|
||||
onClick: closePdf,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
t,
|
||||
selectAllLabel,
|
||||
deselectAllLabel,
|
||||
selectByNumberLabel,
|
||||
deleteSelectedLabel,
|
||||
exportSelectedLabel,
|
||||
closePdfLabel,
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
updatePagesFromCSV,
|
||||
handleSelectAll,
|
||||
handleDeselectAll,
|
||||
handleDelete,
|
||||
onExportSelected,
|
||||
exportLoading,
|
||||
activeFileCount,
|
||||
closePdf,
|
||||
]);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Base Hover Menu */
|
||||
.hoverMenu {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
background: var(--bg-toolbar);
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 30;
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Inside positioning (Page Editor style) - within container */
|
||||
.inside {
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* Outside positioning (File Editor style) - below container */
|
||||
.outside {
|
||||
bottom: -8px;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||
import styles from './HoverActionMenu.module.css';
|
||||
|
||||
export interface HoverAction {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface HoverActionMenuProps {
|
||||
show: boolean;
|
||||
actions: HoverAction[];
|
||||
position?: 'inside' | 'outside';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
show,
|
||||
actions,
|
||||
position = 'inside',
|
||||
className = ''
|
||||
}) => {
|
||||
const visibleActions = actions.filter(action => !action.hidden);
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0 }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{visibleActions.map((action) => (
|
||||
<Tooltip key={action.id} label={action.label}>
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
style={{ color: action.color || 'var(--mantine-color-dimmed)' }}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
c={action.color}
|
||||
>
|
||||
{action.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HoverActionMenu;
|
||||
@@ -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">
|
||||
|
||||
@@ -6,9 +6,10 @@ import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ToastProvider } from '../toast';
|
||||
import ToastRenderer from '../toast/ToastRenderer';
|
||||
import { ToastPortalBinder } from '../toast';
|
||||
import type { ThemeMode } from '../../constants/theme';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
themeMode: ThemeMode;
|
||||
isRainbowMode: boolean;
|
||||
isToggleDisabled: boolean;
|
||||
toggleTheme: () => void;
|
||||
|
||||
@@ -1,447 +1,193 @@
|
||||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { ActionIcon, Divider, Popover } from '@mantine/core';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ActionIcon, Divider } from '@mantine/core';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileSelection } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import LanguageSelector from '../shared/LanguageSelector';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
import { SearchInterface } from '../viewer/SearchInterface';
|
||||
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
|
||||
import LocalIcon from './LocalIcon';
|
||||
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
import { useSidebarContext } from '../../contexts/SidebarContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '../../types/rightRail';
|
||||
|
||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||
|
||||
function renderWithTooltip(
|
||||
node: React.ReactNode,
|
||||
tooltip: React.ReactNode | undefined
|
||||
) {
|
||||
if (!tooltip) return node;
|
||||
|
||||
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} position="left" offset={12} arrow portalTarget={portalTarget}>
|
||||
<div className="right-rail-tooltip-wrapper">{node}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { t } = useTranslation();
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
const { buttons, actions } = useRightRail();
|
||||
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
|
||||
// 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();
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
// Signature context for checking if signatures have been applied
|
||||
const { selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
const { signaturesApplied } = useSignature();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Compute selection state and total items
|
||||
const getSelectionState = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
const totalItems = activeFiles.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
return { totalItems, selectedCount };
|
||||
}
|
||||
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's own state
|
||||
const totalItems = pageEditorFunctions?.totalPages || 0;
|
||||
const selectedCount = pageEditorFunctions?.selectedPageIds?.length || 0;
|
||||
return { totalItems, selectedCount };
|
||||
}
|
||||
|
||||
return { totalItems: 0, selectedCount: 0 };
|
||||
}, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]);
|
||||
|
||||
const { totalItems, selectedCount } = getSelectionState();
|
||||
|
||||
// Get export state for viewer mode
|
||||
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
|
||||
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Select all file IDs
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
// Clear any previous error flags when selecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
const totalItems = useMemo(() => {
|
||||
if (currentView === 'pageEditor') return pageEditorTotalPages;
|
||||
return activeFiles.length;
|
||||
}, [currentView, pageEditorTotalPages, activeFiles.length]);
|
||||
|
||||
const selectedCount = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's select all function
|
||||
pageEditorFunctions?.handleSelectAll?.();
|
||||
return pageEditorSelectedCount;
|
||||
}
|
||||
}, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]);
|
||||
return selectedFileIds.length;
|
||||
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
|
||||
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
setSelectedFiles([]);
|
||||
// Clear any previous error flags when deselecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
if (currentView === 'pageEditor') {
|
||||
// Use PageEditor's deselect all function
|
||||
pageEditorFunctions?.handleDeselectAll?.();
|
||||
}
|
||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||
const sectionsWithButtons = useMemo(() => {
|
||||
return SECTION_ORDER
|
||||
.map(section => {
|
||||
const sectionButtons = buttons.filter(btn => (btn.section ?? 'top') === section && (btn.visible ?? true));
|
||||
return { section, buttons: sectionButtons };
|
||||
})
|
||||
.filter(entry => entry.buttons.length > 0);
|
||||
}, [buttons]);
|
||||
|
||||
const renderButton = useCallback(
|
||||
(btn: RightRailButtonConfig) => {
|
||||
const action = actions[btn.id];
|
||||
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
|
||||
|
||||
const triggerAction = () => {
|
||||
if (!disabled) action?.();
|
||||
};
|
||||
|
||||
if (btn.render) {
|
||||
const context: RightRailRenderContext = {
|
||||
id: btn.id,
|
||||
disabled,
|
||||
allButtonsDisabled,
|
||||
action,
|
||||
triggerAction,
|
||||
};
|
||||
return btn.render(context) ?? null;
|
||||
}
|
||||
|
||||
if (!btn.icon) return null;
|
||||
|
||||
const ariaLabel =
|
||||
btn.ariaLabel || (typeof btn.tooltip === 'string' ? (btn.tooltip as string) : undefined);
|
||||
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
|
||||
const buttonNode = (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className={className}
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
return renderWithTooltip(buttonNode, btn.tooltip);
|
||||
},
|
||||
[actions, allButtonsDisabled, disableForFullscreen]
|
||||
);
|
||||
|
||||
const handleExportAll = useCallback(async () => {
|
||||
if (currentView === 'viewer') {
|
||||
// Check if signatures have been applied
|
||||
if (!signaturesApplied) {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EmbedPDF export functionality for viewer mode
|
||||
viewerContext?.exportActions?.download();
|
||||
} else if (currentView === 'fileEditor') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
} else if (currentView === 'pageEditor') {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
return;
|
||||
}
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
// Close only selected files (do not delete from storage)
|
||||
removeFiles(selectedFileIds, false);
|
||||
|
||||
// Clear selection after closing
|
||||
setSelectedFiles([]);
|
||||
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
|
||||
|
||||
const updatePagesFromCSV = useCallback((override?: string) => {
|
||||
const maxPages = pageEditorFunctions?.totalPages || 0;
|
||||
const normalized = parseSelection(override ?? csvInput, maxPages);
|
||||
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
|
||||
}, [csvInput, pageEditorFunctions]);
|
||||
|
||||
// Do not overwrite user's expression input when selection changes.
|
||||
|
||||
// Clear CSV input when files change (use stable signature to avoid ref churn)
|
||||
useEffect(() => {
|
||||
setCsvInput("");
|
||||
}, [filesSignature]);
|
||||
|
||||
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
|
||||
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
|
||||
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
// Mount and show
|
||||
setPageControlsMounted(true);
|
||||
// Next tick to ensure transition applies
|
||||
requestAnimationFrame(() => setPageControlsVisible(true));
|
||||
} else {
|
||||
// Start exit animation
|
||||
setPageControlsVisible(false);
|
||||
// After transition, unmount to remove flex gap
|
||||
const timer = setTimeout(() => setPageControlsMounted(false), 240);
|
||||
return () => clearTimeout(timer);
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
return;
|
||||
}
|
||||
}, [currentView]);
|
||||
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
}, [
|
||||
currentView,
|
||||
selectedFiles,
|
||||
activeFiles,
|
||||
pageEditorFunctions,
|
||||
viewerContext,
|
||||
signaturesApplied
|
||||
]);
|
||||
|
||||
const downloadTooltip = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
return t('rightRail.exportAll', 'Export PDF');
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
return t('rightRail.downloadSelected', 'Download Selected Files');
|
||||
}
|
||||
return t('rightRail.downloadAll', 'Download All');
|
||||
}, [currentView, selectedCount, t]);
|
||||
|
||||
return (
|
||||
<div className="right-rail">
|
||||
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{topButtons.length > 0 && (
|
||||
<>
|
||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
|
||||
<React.Fragment key={section}>
|
||||
<div className="right-rail-section">
|
||||
{topButtons.map(btn => (
|
||||
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => actions[btn.id]?.()}
|
||||
disabled={btn.disabled}
|
||||
{sectionButtons.map((btn, index) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="right-rail-button-wrapper"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
|
||||
<div
|
||||
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView !== 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
{/* Search */}
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={currentView !== 'viewer'}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface
|
||||
visible={true}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
{/* Pan Mode */}
|
||||
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant={isPanning ? "filled" : "subtle"}
|
||||
color={isPanning ? "blue" : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.panActions.togglePan();
|
||||
setIsPanning(!isPanning);
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<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>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateBackward();
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<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>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateForward();
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<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>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleThumbnailSidebar();
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Controls */}
|
||||
<ViewerAnnotationControls currentView={currentView} />
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView === 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
{/* Select All Button */}
|
||||
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleSelectAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
|
||||
>
|
||||
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Deselect All Button */}
|
||||
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={currentView === 'viewer' || selectedCount === 0}
|
||||
>
|
||||
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Select by Numbers - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={!pageControlsVisible || totalItems === 0}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={Array.isArray(pageEditorFunctions?.selectedPageIds) ? pageEditorFunctions.selectedPageIds : []}
|
||||
displayDocument={pageEditorFunctions?.displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Delete Selected Pages - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
|
||||
>
|
||||
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Export Selected Pages - page editor only */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
|
||||
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
|
||||
disabled={
|
||||
currentView === 'viewer' ||
|
||||
(currentView === 'fileEditor' && selectedCount === 0) ||
|
||||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
|
||||
{/* Theme toggle and Language dropdown */}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
@@ -449,30 +195,32 @@ export default function RightRail() {
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon>,
|
||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
||||
)}
|
||||
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
{renderWithTooltip(
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
</div>,
|
||||
t('rightRail.language', 'Language')
|
||||
)}
|
||||
|
||||
<Tooltip content={
|
||||
currentView === 'pageEditor'
|
||||
? t('rightRail.exportAll', 'Export PDF')
|
||||
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||
} position="left" offset={12} arrow>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
(currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>,
|
||||
downloadTooltip
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="right-rail-spacer" />
|
||||
@@ -480,5 +228,3 @@ export default function RightRail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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, customViews)}
|
||||
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 type { ToolPanelMode } from '../../../../constants/toolPanel';
|
||||
|
||||
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>
|
||||
|
||||
@@ -84,14 +84,48 @@ useRightRailButtons([
|
||||
```typescript
|
||||
interface RightRailButtonWithAction {
|
||||
id: string; // Unique identifier
|
||||
icon: React.ReactNode; // Icon component
|
||||
tooltip: string; // Hover tooltip
|
||||
icon?: React.ReactNode; // Icon component (omit when using render)
|
||||
tooltip?: React.ReactNode; // Hover tooltip / description
|
||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||
order?: number; // Sort order (default: 0)
|
||||
disabled?: boolean; // Disabled state (default: false)
|
||||
visible?: boolean; // Visibility (default: true)
|
||||
onClick: () => void; // Click handler
|
||||
render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer
|
||||
onClick?: () => void; // Click handler (optional if using render)
|
||||
}
|
||||
|
||||
interface RightRailRenderContext {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
allButtonsDisabled: boolean;
|
||||
action?: () => void;
|
||||
triggerAction: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Rendering (Popovers, Multi-button Blocks)
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: t('rightRail.search', 'Search PDF'),
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')}>
|
||||
<Popover position="left">
|
||||
<Popover.Target>
|
||||
<ActionIcon disabled={disabled}>
|
||||
<SearchIcon />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<SearchInterface />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## Built-in Features
|
||||
@@ -106,3 +140,4 @@ interface RightRailButtonWithAction {
|
||||
- Choose appropriate Material-UI icons
|
||||
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
|
||||
- Use `useCallback` for click handlers to prevent re-registration
|
||||
- Reach for `render` when you need popovers or multi-control groups inside the rail
|
||||
|
||||
@@ -29,6 +29,34 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.right-rail-button-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
animation: rightRailButtonReveal 200ms ease forwards;
|
||||
transform-origin: top center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.right-rail-tooltip-wrapper {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes rightRailButtonReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.6) translateY(-6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scaleY(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.right-rail-divider {
|
||||
width: 2.75rem;
|
||||
border: none;
|
||||
@@ -46,6 +74,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;
|
||||
}
|
||||
@@ -124,4 +159,3 @@
|
||||
transition-delay: 0s, 0s, 0s, 220ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { usePreferences } from '../../contexts/PreferencesContext';
|
||||
import ToolPicker from './ToolPicker';
|
||||
import SearchResults from './SearchResults';
|
||||
import ToolRenderer from './ToolRenderer';
|
||||
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 { 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,180 @@ 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 { preferences, updatePreference } = usePreferences();
|
||||
|
||||
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]);
|
||||
|
||||
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={preferences.showLegacyToolDescriptions}
|
||||
matchedTextMap={matchedTextMap}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelect={(id: ToolId) => handleToolSelect(id)}
|
||||
onToggleDescriptions={() => updatePreference('showLegacyToolDescriptions', !preferences.showLegacyToolDescriptions)}
|
||||
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,141 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { usePreferences } from '../../contexts/PreferencesContext';
|
||||
import './ToolPanelModePrompt.css';
|
||||
import type { ToolPanelMode } from '../../constants/toolPanel';
|
||||
|
||||
const ToolPanelModePrompt = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const shouldShowPrompt = !preferences.toolPanelModePromptSeen;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowPrompt) {
|
||||
setOpened(true);
|
||||
}
|
||||
}, [shouldShowPrompt]);
|
||||
|
||||
const handleSelect = (mode: ToolPanelMode) => {
|
||||
setToolPanelMode(mode);
|
||||
updatePreference('defaultToolPanelMode', mode);
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
updatePreference('toolPanelModePromptSeen', true);
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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,223 @@
|
||||
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';
|
||||
import FitText from '../../shared/FitText';
|
||||
import { SuggestedToolsSection } from '../shared/SuggestedToolsSection';
|
||||
|
||||
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 isValid = signature.valid;
|
||||
if (isValid) {
|
||||
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" style={{ maxHeight: '20rem', overflowY: 'auto' }}>
|
||||
{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.invalid', 'Invalid')
|
||||
: 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="flex-start">
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<FitText text={result.fileName} lines={2} as="div" minimumFontScale={0.5} style={{ fontWeight: 600 }} />
|
||||
</div>
|
||||
<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 && 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}
|
||||
/>
|
||||
<Button
|
||||
color="blue"
|
||||
onClick={() => selectedFile && handleDownload(selectedFile)}
|
||||
disabled={!selectedFile}
|
||||
fullWidth
|
||||
>
|
||||
{selectedDownloadLabel}
|
||||
</Button>
|
||||
{selectedType === 'pdf' && !pdfFile && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.noPdf', 'PDF report will be available after a successful validation.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<SuggestedToolsSection />
|
||||
</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";
|
||||
@@ -14,12 +13,15 @@ import { useSignature } from '../../contexts/SignatureContext';
|
||||
import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers';
|
||||
import NavigationWarningModal from '../shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '../../types/fileContext';
|
||||
import { useViewerRightRailButtons } from './useViewerRightRailButtons';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
const EmbedPdfViewerContent = ({
|
||||
@@ -27,14 +29,17 @@ 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);
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const zoomState = getZoomState();
|
||||
const spreadState = getSpreadState();
|
||||
@@ -52,10 +57,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 +73,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 +125,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 +159,7 @@ const EmbedPdfViewerContent = ({
|
||||
}, [zoomActions]);
|
||||
|
||||
// Handle keyboard zoom shortcuts
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isViewerHovered) return;
|
||||
|
||||
@@ -244,15 +275,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 +339,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,125 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewer } from '../../contexts/ViewerContext';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { SearchInterface } from './SearchInterface';
|
||||
import ViewerAnnotationControls from '../shared/rightRail/ViewerAnnotationControls';
|
||||
|
||||
export function useViewerRightRailButtons() {
|
||||
const { t } = useTranslation();
|
||||
const viewer = useViewer();
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||
const panLabel = t('rightRail.panMode', 'Pan Mode');
|
||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: searchLabel,
|
||||
ariaLabel: searchLabel,
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={searchLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled}
|
||||
aria-label={searchLabel}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface visible={true} onClose={() => {}} />
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-pan-mode',
|
||||
tooltip: panLabel,
|
||||
ariaLabel: panLabel,
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanning ? 'filled' : 'subtle'}
|
||||
color={isPanning ? 'blue' : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewer.panActions.togglePan();
|
||||
setIsPanning(prev => !prev);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-left',
|
||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: rotateLeftLabel,
|
||||
ariaLabel: rotateLeftLabel,
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
onClick: () => {
|
||||
viewer.rotationActions.rotateBackward();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-right',
|
||||
icon: <LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: rotateRightLabel,
|
||||
ariaLabel: rotateRightLabel,
|
||||
section: 'top' as const,
|
||||
order: 40,
|
||||
onClick: () => {
|
||||
viewer.rotationActions.rotateForward();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-toggle-sidebar',
|
||||
icon: <LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: sidebarLabel,
|
||||
ariaLabel: sidebarLabel,
|
||||
section: 'top' as const,
|
||||
order: 50,
|
||||
onClick: () => {
|
||||
viewer.toggleThumbnailSidebar();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotation-controls',
|
||||
section: 'top' as const,
|
||||
order: 60,
|
||||
render: ({ disabled }) => (
|
||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Theme constants and utilities
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'rainbow';
|
||||
|
||||
// Detect OS theme preference
|
||||
export function getSystemTheme(): 'light' | 'dark' {
|
||||
return window?.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Tool panel constants
|
||||
|
||||
export type ToolPanelMode = 'sidebar' | 'fullscreen';
|
||||
|
||||
export const DEFAULT_TOOL_PANEL_MODE: ToolPanelMode = 'sidebar';
|
||||
@@ -79,8 +79,21 @@ function FileContextInner({
|
||||
};
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const stirlingFiles = await addFiles({ files, ...options }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence);
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean; skipAutoUnzip?: boolean }): Promise<StirlingFile[]> => {
|
||||
const stirlingFiles = await addFiles(
|
||||
{
|
||||
files,
|
||||
...options,
|
||||
// For direct file uploads: ALWAYS unzip (except HTML ZIPs)
|
||||
// skipAutoUnzip bypasses preference checks - HTML detection still applies
|
||||
skipAutoUnzip: true
|
||||
},
|
||||
stateRef,
|
||||
filesRef,
|
||||
dispatch,
|
||||
lifecycleManager,
|
||||
enablePersistence
|
||||
);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && stirlingFiles.length > 0) {
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { preferencesService, UserPreferences, DEFAULT_PREFERENCES } from '../services/preferencesService';
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { preferencesService, UserPreferences } from '../services/preferencesService';
|
||||
|
||||
interface PreferencesContextValue {
|
||||
preferences: UserPreferences;
|
||||
updatePreference: <K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
) => Promise<void>;
|
||||
resetPreferences: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
) => void;
|
||||
resetPreferences: () => void;
|
||||
}
|
||||
|
||||
const PreferencesContext = createContext<PreferencesContextValue | undefined>(undefined);
|
||||
|
||||
export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
await preferencesService.initialize();
|
||||
const loadedPreferences = await preferencesService.getAllPreferences();
|
||||
setPreferences(loadedPreferences);
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
// Keep default preferences on error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPreferences();
|
||||
}, []);
|
||||
const [preferences, setPreferences] = useState<UserPreferences>(() => {
|
||||
// Load preferences synchronously on mount
|
||||
return preferencesService.getAllPreferences();
|
||||
});
|
||||
|
||||
const updatePreference = useCallback(
|
||||
async <K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
|
||||
await preferencesService.setPreference(key, value);
|
||||
setPreferences((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
<K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
|
||||
preferencesService.setPreference(key, value);
|
||||
setPreferences((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetPreferences = useCallback(async () => {
|
||||
await preferencesService.clearAllPreferences();
|
||||
setPreferences(DEFAULT_PREFERENCES);
|
||||
const resetPreferences = useCallback(() => {
|
||||
preferencesService.clearAllPreferences();
|
||||
setPreferences(preferencesService.getAllPreferences());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -56,7 +40,6 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||
preferences,
|
||||
updatePreference,
|
||||
resetPreferences,
|
||||
isLoading,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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;
|
||||
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 => {
|
||||
@@ -39,8 +42,20 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) {
|
||||
setActions(prev => Object.fromEntries(Object.entries(prev).filter(([id]) => !ids.includes(id))));
|
||||
}, []);
|
||||
|
||||
const setAction = useCallback((id: string, action: RightRailAction) => {
|
||||
setActions(prev => ({ ...prev, [id]: action }));
|
||||
const setAction = useCallback((id: string, action?: RightRailAction) => {
|
||||
setActions(prev => {
|
||||
if (!action) {
|
||||
if (!(id in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
}
|
||||
return { ...prev, [id]: action };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAllRightRailButtonsDisabled = useCallback((disabled: boolean) => {
|
||||
setAllButtonsDisabled(disabled);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
@@ -48,7 +63,16 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) {
|
||||
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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '../types/workbench';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
import { getDefaultWorkbench } from '../types/workbench';
|
||||
import { filterToolRegistryByQuery } from '../utils/toolSearch';
|
||||
import { useToolHistory } from '../hooks/tools/useUserToolActivity';
|
||||
import {
|
||||
ToolWorkflowState,
|
||||
createInitialState,
|
||||
toolWorkflowReducer,
|
||||
} from './toolWorkflow/toolWorkflowState';
|
||||
import type { ToolPanelMode } from '../constants/toolPanel';
|
||||
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, updatePreference } = 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,12 @@ 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 });
|
||||
updatePreference('defaultToolPanelMode', mode);
|
||||
}, [updatePreference]);
|
||||
|
||||
|
||||
const setPreviewFile = useCallback((file: File | null) => {
|
||||
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
|
||||
if (file) {
|
||||
@@ -163,6 +164,83 @@ 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]);
|
||||
|
||||
// Persisted via PreferencesContext; no direct localStorage writes needed here
|
||||
|
||||
// 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(() => {
|
||||
const preferredMode = preferences.defaultToolPanelMode;
|
||||
if (preferredMode !== state.toolPanelMode) {
|
||||
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: preferredMode });
|
||||
}
|
||||
}, [preferences.defaultToolPanelMode, state.toolPanelMode]);
|
||||
|
||||
// Tool reset methods
|
||||
const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => {
|
||||
setToolResetFunctions(prev => ({ ...prev, [toolId]: resetFunction }));
|
||||
@@ -181,21 +259,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 +288,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 +300,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,7 +323,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
[state.sidebarsVisible, state.readerMode, state.leftPanelView]
|
||||
);
|
||||
|
||||
// URL sync for proper tool navigation
|
||||
useNavigationUrlSync(
|
||||
navigationState.selectedTool,
|
||||
handleToolSelect,
|
||||
@@ -261,6 +344,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setToolPanelMode,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
@@ -280,6 +364,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 +385,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
setToolPanelMode,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
@@ -300,6 +397,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
handleReaderToggle,
|
||||
filteredTools,
|
||||
isPanelVisible,
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
customWorkbenchViews,
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -313,7 +418,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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FileLifecycleManager } from './lifecycle';
|
||||
import { buildQuickKeySet } from './fileSelectors';
|
||||
import { StirlingFile } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
@@ -172,6 +173,11 @@ interface AddFileOptions {
|
||||
|
||||
// Auto-selection after adding
|
||||
selectFiles?: boolean;
|
||||
|
||||
// Auto-unzip control
|
||||
autoUnzip?: boolean;
|
||||
autoUnzipFileLimit?: number;
|
||||
skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,7 +204,58 @@ export async function addFiles(
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
|
||||
// ZIP pre-processing: Extract ZIP files with configurable behavior
|
||||
// - File uploads: skipAutoUnzip=true → always extract (except HTML)
|
||||
// - Tool outputs: skipAutoUnzip=false → respect user preferences
|
||||
const filesToProcess: File[] = [];
|
||||
const autoUnzip = options.autoUnzip ?? true; // Default to true
|
||||
const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit
|
||||
const skipAutoUnzip = options.skipAutoUnzip ?? false;
|
||||
|
||||
for (const file of files) {
|
||||
// Check if file is a ZIP
|
||||
if (zipFileService.isZipFile(file)) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles: Detected ZIP file: ${file.name}`);
|
||||
|
||||
// Check if ZIP contains HTML files - if so, keep as ZIP
|
||||
const containsHtml = await zipFileService.containsHtmlFiles(file);
|
||||
if (containsHtml) {
|
||||
if (DEBUG) console.log(`📄 addFiles: ZIP contains HTML, keeping as ZIP: ${file.name}`);
|
||||
filesToProcess.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply extraction with preferences
|
||||
const extractedFiles = await zipFileService.extractWithPreferences(file, {
|
||||
autoUnzip,
|
||||
autoUnzipFileLimit,
|
||||
skipAutoUnzip
|
||||
});
|
||||
|
||||
if (extractedFiles.length === 1 && extractedFiles[0] === file) {
|
||||
// ZIP was not extracted (over limit or autoUnzip disabled)
|
||||
if (DEBUG) console.log(`📄 addFiles: ZIP not extracted (preferences): ${file.name}`);
|
||||
} else {
|
||||
// ZIP was extracted
|
||||
if (DEBUG) console.log(`📄 addFiles: Extracted ${extractedFiles.length} files from ZIP: ${file.name}`);
|
||||
}
|
||||
|
||||
filesToProcess.push(...extractedFiles);
|
||||
} catch (error) {
|
||||
console.error(`📄 addFiles: Failed to process ZIP file ${file.name}:`, error);
|
||||
// On error, keep the ZIP file as-is
|
||||
filesToProcess.push(file);
|
||||
}
|
||||
} else {
|
||||
// Not a ZIP file, add as-is
|
||||
filesToProcess.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles: After ZIP processing, ${filesToProcess.length} files to add`);
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { PageEditorFunctions } from '../../types/pageEditor';
|
||||
import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '../../constants/toolPanel';
|
||||
|
||||
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' };
|
||||
|
||||
|
||||
export const baseState: Omit<ToolWorkflowState, 'toolPanelMode'> = {
|
||||
sidebarsVisible: true,
|
||||
leftPanelView: 'toolPicker',
|
||||
readerMode: false,
|
||||
previewFile: null,
|
||||
pageEditorFunctions: null,
|
||||
searchQuery: '',
|
||||
};
|
||||
|
||||
export const createInitialState = (): ToolWorkflowState => ({
|
||||
...baseState,
|
||||
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user