mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cd1491956 | ||
|
|
531e22b2e2 | ||
|
|
93ed05b054 | ||
|
|
195b1472e4 | ||
|
|
340006ceea | ||
|
|
d80e627899 | ||
|
|
336ec34125 | ||
|
|
5f72c05623 | ||
|
|
33188815da | ||
|
|
0064c1866e | ||
|
|
371d816ce7 | ||
|
|
5f54308d2b | ||
|
|
6f7b8ce433 | ||
|
|
69ffd29bb5 | ||
|
|
f4cc87144d | ||
|
|
c86e2d6840 | ||
|
|
5f072f87bb | ||
|
|
eb3e57577c | ||
|
|
f29d85565a | ||
|
|
ae72344317 |
@@ -1,12 +0,0 @@
|
||||
# CI Configuration
|
||||
|
||||
## CI Lite Mode
|
||||
|
||||
Skip non-essential CI workflows by setting a repository variable:
|
||||
|
||||
**Settings → Secrets and variables → Actions → Variables → New repository variable**
|
||||
|
||||
- Name: `CI_PROFILE`
|
||||
- Value: `lite`
|
||||
|
||||
Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs.
|
||||
@@ -52,7 +52,6 @@ jobs:
|
||||
core.setOutput('repository', pr.head.repo.full_name);
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('is_fork', String(pr.head.repo.fork));
|
||||
core.setOutput('base_ref', pr.base.ref);
|
||||
core.setOutput('author', pr.user.login);
|
||||
core.setOutput('state', pr.state);
|
||||
|
||||
@@ -65,10 +64,6 @@ jobs:
|
||||
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
|
||||
# nur bei workflow_dispatch gesetzt:
|
||||
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
|
||||
# für Auto-PR-Logik:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
|
||||
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
|
||||
run: |
|
||||
set -e
|
||||
@@ -89,14 +84,8 @@ jobs:
|
||||
else
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs")
|
||||
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
|
||||
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
|
||||
if [ "$is_auth" = true ]; then
|
||||
should=true
|
||||
else
|
||||
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
|
||||
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
|
||||
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
|
||||
should=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -174,7 +163,7 @@ jobs:
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
});
|
||||
return newComment.id;
|
||||
|
||||
@@ -394,7 +383,7 @@ jobs:
|
||||
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
|
||||
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
|
||||
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
|
||||
`🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`;
|
||||
`🔄 **Auto-deployed** for approved V2 contributors.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
|
||||
@@ -491,6 +491,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Ghostscript", "repair");
|
||||
addEndpointToGroup("Ghostscript", "compress-pdf");
|
||||
|
||||
/* ImageMagick */
|
||||
addEndpointToGroup("ImageMagick", "compress-pdf");
|
||||
|
||||
/* tesseract */
|
||||
addEndpointToGroup("tesseract", "ocr-pdf");
|
||||
|
||||
@@ -574,6 +577,7 @@ public class EndpointConfiguration {
|
||||
|| "Javascript".equals(group)
|
||||
|| "Weasyprint".equals(group)
|
||||
|| "Pdftohtml".equals(group)
|
||||
|| "ImageMagick".equals(group)
|
||||
|| "rar".equals(group);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ public class ApplicationProperties {
|
||||
|
||||
private AutoPipeline autoPipeline = new AutoPipeline();
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
|
||||
@Bean
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
@@ -100,6 +101,46 @@ public class ApplicationProperties {
|
||||
private String outputFolder;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PdfEditor {
|
||||
private Cache cache = new Cache();
|
||||
private FontNormalization fontNormalization = new FontNormalization();
|
||||
private CffConverter cffConverter = new CffConverter();
|
||||
private Type3 type3 = new Type3();
|
||||
private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf";
|
||||
|
||||
@Data
|
||||
public static class Cache {
|
||||
private long maxBytes = -1;
|
||||
private int maxPercent = 20;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class FontNormalization {
|
||||
private boolean enabled = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CffConverter {
|
||||
private boolean enabled = true;
|
||||
private String method = "python";
|
||||
private String pythonCommand = "/opt/venv/bin/python3";
|
||||
private String pythonScript = "/scripts/convert_cff_to_ttf.py";
|
||||
private String fontforgeCommand = "fontforge";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Type3 {
|
||||
private Library library = new Library();
|
||||
|
||||
@Data
|
||||
public static class Library {
|
||||
private boolean enabled = true;
|
||||
private String index = "classpath:/type3/library/index.json";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Legal {
|
||||
private String termsAndConditions;
|
||||
@@ -357,6 +398,7 @@ public class ApplicationProperties {
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Boolean enableDesktopInstallSlide;
|
||||
private Datasource datasource;
|
||||
private Boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
@@ -367,10 +409,12 @@ public class ApplicationProperties {
|
||||
private TempFileManagement tempFileManagement = new TempFileManagement();
|
||||
private DatabaseBackup databaseBackup = new DatabaseBackup();
|
||||
private List<String> corsAllowedOrigins = new ArrayList<>();
|
||||
private String
|
||||
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
|
||||
private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g.
|
||||
// 'http://localhost:8080', 'https://api.example.com'). Required for
|
||||
// SSO.
|
||||
private String frontendUrl; // Frontend URL for invite email links (e.g.
|
||||
|
||||
// falls back to backend URL.
|
||||
// 'https://app.example.com'). If not set, falls back to backendUrl.
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
@@ -535,6 +579,7 @@ public class ApplicationProperties {
|
||||
@ToString.Exclude private String key;
|
||||
private String UUID;
|
||||
private String appVersion;
|
||||
private Boolean isNewServer;
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
@@ -652,6 +697,7 @@ public class ApplicationProperties {
|
||||
private int weasyPrintSessionLimit;
|
||||
private int installAppSessionLimit;
|
||||
private int calibreSessionLimit;
|
||||
private int imageMagickSessionLimit;
|
||||
private int qpdfSessionLimit;
|
||||
private int tesseractSessionLimit;
|
||||
private int ghostscriptSessionLimit;
|
||||
@@ -689,6 +735,10 @@ public class ApplicationProperties {
|
||||
return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
|
||||
}
|
||||
|
||||
public int getImageMagickSessionLimit() {
|
||||
return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
|
||||
}
|
||||
|
||||
public int getGhostscriptSessionLimit() {
|
||||
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
|
||||
}
|
||||
@@ -718,6 +768,8 @@ public class ApplicationProperties {
|
||||
@JsonProperty("calibretimeoutMinutes")
|
||||
private long calibreTimeoutMinutes;
|
||||
|
||||
private long imageMagickTimeoutMinutes;
|
||||
|
||||
private long tesseractTimeoutMinutes;
|
||||
private long qpdfTimeoutMinutes;
|
||||
private long ghostscriptTimeoutMinutes;
|
||||
@@ -755,6 +807,10 @@ public class ApplicationProperties {
|
||||
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getImageMagickTimeoutMinutes() {
|
||||
return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getGhostscriptTimeoutMinutes() {
|
||||
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
|
||||
public interface LineArtConversionService {
|
||||
PDImageXObject convertImageToLineArt(
|
||||
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
|
||||
throws IOException;
|
||||
}
|
||||
@@ -86,6 +86,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getCalibreSessionLimit();
|
||||
case IMAGEMAGICK ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getImageMagickSessionLimit();
|
||||
case GHOSTSCRIPT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
@@ -141,6 +146,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getCalibreTimeoutMinutes();
|
||||
case IMAGEMAGICK ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getImageMagickTimeoutMinutes();
|
||||
case GHOSTSCRIPT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
@@ -301,6 +311,7 @@ public class ProcessExecutor {
|
||||
WEASYPRINT,
|
||||
INSTALL_APP,
|
||||
CALIBRE,
|
||||
IMAGEMAGICK,
|
||||
TESSERACT,
|
||||
QPDF,
|
||||
GHOSTSCRIPT,
|
||||
|
||||
@@ -26,6 +26,7 @@ public class RequestUriUtils {
|
||||
|| normalizedUri.startsWith("/public/")
|
||||
|| normalizedUri.startsWith("/pdfjs/")
|
||||
|| normalizedUri.startsWith("/pdfjs-legacy/")
|
||||
|| normalizedUri.startsWith("/pdfium/")
|
||||
|| normalizedUri.startsWith("/assets/")
|
||||
|| normalizedUri.startsWith("/locales/")
|
||||
|| normalizedUri.startsWith("/Login/")
|
||||
@@ -61,7 +62,8 @@ public class RequestUriUtils {
|
||||
|| normalizedUri.endsWith(".css")
|
||||
|| normalizedUri.endsWith(".mjs")
|
||||
|| normalizedUri.endsWith(".html")
|
||||
|| normalizedUri.endsWith(".toml");
|
||||
|| normalizedUri.endsWith(".toml")
|
||||
|| normalizedUri.endsWith(".wasm");
|
||||
}
|
||||
|
||||
public static boolean isFrontendRoute(String contextPath, String requestURI) {
|
||||
@@ -125,11 +127,13 @@ public class RequestUriUtils {
|
||||
|| requestURI.endsWith("popularity.txt")
|
||||
|| requestURI.endsWith(".js")
|
||||
|| requestURI.endsWith(".toml")
|
||||
|| requestURI.endsWith(".wasm")
|
||||
|| requestURI.contains("swagger")
|
||||
|| requestURI.startsWith("/api/v1/info")
|
||||
|| requestURI.startsWith("/site.webmanifest")
|
||||
|| requestURI.startsWith("/fonts")
|
||||
|| requestURI.startsWith("/pdfjs"));
|
||||
|| requestURI.startsWith("/pdfjs")
|
||||
|| requestURI.startsWith("/pdfium"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,9 @@ public class RequestUriUtilsTest {
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"),
|
||||
"PDF.js files should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should be static");
|
||||
assertTrue(
|
||||
RequestUriUtils.isStaticResource("/api/v1/info/status"),
|
||||
"API status should be static");
|
||||
@@ -110,7 +113,8 @@ public class RequestUriUtilsTest {
|
||||
"/downloads/document.png",
|
||||
"/assets/brand.ico",
|
||||
"/any/path/with/image.svg",
|
||||
"/deep/nested/folder/icon.png"
|
||||
"/deep/nested/folder/icon.png",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testIsStaticResourceWithFileExtensions(String path) {
|
||||
assertTrue(
|
||||
@@ -148,6 +152,9 @@ public class RequestUriUtilsTest {
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/script.js"),
|
||||
"JS files should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"),
|
||||
"PDFium wasm should not be trackable");
|
||||
assertFalse(
|
||||
RequestUriUtils.isTrackableResource("/swagger/index.html"),
|
||||
"Swagger files should not be trackable");
|
||||
@@ -224,7 +231,8 @@ public class RequestUriUtilsTest {
|
||||
"/api/v1/info/health",
|
||||
"/site.webmanifest",
|
||||
"/fonts/roboto.woff",
|
||||
"/pdfjs/viewer.js"
|
||||
"/pdfjs/viewer.js",
|
||||
"/pdfium/pdfium.wasm"
|
||||
})
|
||||
void testNonTrackableResources(String path) {
|
||||
assertFalse(
|
||||
|
||||
@@ -46,6 +46,7 @@ public class ExternalAppDepConfig {
|
||||
put("qpdf", List.of("qpdf"));
|
||||
put("tesseract", List.of("tesseract"));
|
||||
put("rar", List.of("rar")); // Required for real CBR output
|
||||
put("magick", List.of("ImageMagick"));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -128,6 +129,7 @@ public class ExternalAppDepConfig {
|
||||
checkDependencyAndDisableGroup("pdftohtml");
|
||||
checkDependencyAndDisableGroup(unoconvPath);
|
||||
checkDependencyAndDisableGroup("rar");
|
||||
checkDependencyAndDisableGroup("magick");
|
||||
// Special handling for Python/OpenCV dependencies
|
||||
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
|
||||
if (!pythonAvailable) {
|
||||
|
||||
@@ -94,6 +94,7 @@ public class InitialSetup {
|
||||
}
|
||||
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
|
||||
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
|
||||
applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer);
|
||||
}
|
||||
|
||||
public static boolean isNewServer() {
|
||||
|
||||
@@ -33,14 +33,35 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// Cache hashed assets (JS/CSS with content hashes) for 1 year
|
||||
// These files have names like index-ChAS4tCC.js that change when content changes
|
||||
// Check customFiles/static first, then fall back to classpath
|
||||
registry.addResourceHandler("/assets/**")
|
||||
.addResourceLocations("classpath:/static/assets/")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath()
|
||||
+ "assets/",
|
||||
"classpath:/static/assets/")
|
||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
|
||||
|
||||
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
|
||||
// Note: index.html is handled by ReactRoutingController for dynamic processing
|
||||
registry.addResourceHandler("/index.html")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
|
||||
// Handle all other static resources (js, css, images, fonts, etc.)
|
||||
// Check customFiles/static first for user overrides
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.exception.CacheUnavailableException;
|
||||
|
||||
@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class)
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPdfJsonExceptionHandler {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@ExceptionHandler(CacheUnavailableException.class)
|
||||
@ResponseBody
|
||||
public ResponseEntity<byte[]> handleCacheUnavailable(CacheUnavailableException ex) {
|
||||
try {
|
||||
byte[] body =
|
||||
objectMapper.writeValueAsBytes(
|
||||
java.util.Map.of(
|
||||
"error", "cache_unavailable",
|
||||
"action", "reupload",
|
||||
"message", ex.getMessage()));
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to serialize cache_unavailable response", e);
|
||||
var fallbackBody =
|
||||
java.util.Map.of(
|
||||
"error", "cache_unavailable",
|
||||
"action", "reupload",
|
||||
"message", String.valueOf(ex.getMessage()));
|
||||
try {
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(objectMapper.writeValueAsBytes(fallbackBody));
|
||||
} catch (Exception ignored) {
|
||||
// Truly last-ditch fallback
|
||||
return ResponseEntity.status(HttpStatus.GONE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(
|
||||
"{\"error\":\"cache_unavailable\",\"action\":\"reupload\",\"message\":\"Cache unavailable\"}"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -28,10 +28,13 @@ import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
@@ -44,6 +47,7 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
@@ -58,6 +62,9 @@ public class CompressController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@Autowired(required = false)
|
||||
private LineArtConversionService lineArtConversionService;
|
||||
|
||||
private boolean isQpdfEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("qpdf");
|
||||
}
|
||||
@@ -66,6 +73,10 @@ public class CompressController {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
private boolean isImageMagickEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("ImageMagick");
|
||||
}
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@@ -660,6 +671,9 @@ public class CompressController {
|
||||
Integer optimizeLevel = request.getOptimizeLevel();
|
||||
String expectedOutputSizeString = request.getExpectedOutputSize();
|
||||
Boolean convertToGrayscale = request.getGrayscale();
|
||||
Boolean convertToLineArt = request.getLineArt();
|
||||
Double lineArtThreshold = request.getLineArtThreshold();
|
||||
Integer lineArtEdgeLevel = request.getLineArtEdgeLevel();
|
||||
if (expectedOutputSizeString == null && optimizeLevel == null) {
|
||||
throw new Exception("Both expected output size and optimize level are not specified");
|
||||
}
|
||||
@@ -689,6 +703,26 @@ public class CompressController {
|
||||
optimizeLevel = determineOptimizeLevel(sizeReductionRatio);
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(convertToLineArt)) {
|
||||
if (lineArtConversionService == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"Line art conversion is unavailable - ImageMagick service not found");
|
||||
}
|
||||
if (!isImageMagickEnabled()) {
|
||||
throw new IOException(
|
||||
"ImageMagick is not enabled but line art conversion was requested");
|
||||
}
|
||||
double thresholdValue =
|
||||
lineArtThreshold == null
|
||||
? 55d
|
||||
: Math.min(100d, Math.max(0d, lineArtThreshold));
|
||||
int edgeLevel =
|
||||
lineArtEdgeLevel == null ? 1 : Math.min(3, Math.max(1, lineArtEdgeLevel));
|
||||
currentFile =
|
||||
applyLineArtConversion(currentFile, tempFiles, thresholdValue, edgeLevel);
|
||||
}
|
||||
|
||||
boolean sizeMet = false;
|
||||
boolean imageCompressionApplied = false;
|
||||
boolean externalCompressionApplied = false;
|
||||
@@ -810,6 +844,75 @@ public class CompressController {
|
||||
}
|
||||
}
|
||||
|
||||
private Path applyLineArtConversion(
|
||||
Path currentFile, List<Path> tempFiles, double threshold, int edgeLevel)
|
||||
throws IOException {
|
||||
|
||||
Path lineArtFile = Files.createTempFile("lineart_output_", ".pdf");
|
||||
tempFiles.add(lineArtFile);
|
||||
|
||||
try (PDDocument doc = pdfDocumentFactory.load(currentFile.toFile())) {
|
||||
Map<String, List<ImageReference>> uniqueImages = findImages(doc);
|
||||
CompressionStats stats = new CompressionStats();
|
||||
stats.uniqueImagesCount = uniqueImages.size();
|
||||
calculateImageStats(uniqueImages, stats);
|
||||
|
||||
Map<String, PDImageXObject> convertedImages =
|
||||
createLineArtImages(doc, uniqueImages, stats, threshold, edgeLevel);
|
||||
|
||||
replaceImages(doc, uniqueImages, convertedImages, stats);
|
||||
|
||||
log.info(
|
||||
"Applied line art conversion to {} unique images ({} total references)",
|
||||
stats.uniqueImagesCount,
|
||||
stats.totalImages);
|
||||
|
||||
doc.save(lineArtFile.toString());
|
||||
return lineArtFile;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, PDImageXObject> createLineArtImages(
|
||||
PDDocument doc,
|
||||
Map<String, List<ImageReference>> uniqueImages,
|
||||
CompressionStats stats,
|
||||
double threshold,
|
||||
int edgeLevel)
|
||||
throws IOException {
|
||||
|
||||
Map<String, PDImageXObject> convertedImages = new HashMap<>();
|
||||
|
||||
for (Entry<String, List<ImageReference>> entry : uniqueImages.entrySet()) {
|
||||
String imageHash = entry.getKey();
|
||||
List<ImageReference> references = entry.getValue();
|
||||
if (references.isEmpty()) continue;
|
||||
|
||||
PDImageXObject originalImage = getOriginalImage(doc, references.get(0));
|
||||
|
||||
int originalSize = (int) originalImage.getCOSObject().getLength();
|
||||
stats.totalOriginalBytes += originalSize;
|
||||
|
||||
PDImageXObject converted =
|
||||
lineArtConversionService.convertImageToLineArt(
|
||||
doc, originalImage, threshold, edgeLevel);
|
||||
convertedImages.put(imageHash, converted);
|
||||
stats.compressedImages++;
|
||||
|
||||
int convertedSize = (int) converted.getCOSObject().getLength();
|
||||
stats.totalCompressedBytes += convertedSize * references.size();
|
||||
|
||||
double reductionPercentage = 100.0 - ((convertedSize * 100.0) / originalSize);
|
||||
log.info(
|
||||
"Image hash {}: Line art conversion {} → {} (reduced by {}%)",
|
||||
imageHash,
|
||||
GeneralUtils.formatBytes(originalSize),
|
||||
GeneralUtils.formatBytes(convertedSize),
|
||||
String.format("%.1f", reductionPercentage));
|
||||
}
|
||||
|
||||
return convertedImages;
|
||||
}
|
||||
|
||||
// Run Ghostscript compression
|
||||
private void applyGhostscriptCompression(
|
||||
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
|
||||
|
||||
+9
@@ -124,6 +124,9 @@ public class ConfigController {
|
||||
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
|
||||
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
|
||||
configData.put(
|
||||
"enableDesktopInstallSlide",
|
||||
applicationProperties.getSystem().getEnableDesktopInstallSlide());
|
||||
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
@@ -227,4 +230,10 @@ public class ConfigController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/group-enabled")
|
||||
public ResponseEntity<Boolean> isGroupEnabled(@RequestParam(name = "group") String group) {
|
||||
boolean enabled = endpointConfiguration.isGroupEnabled(group);
|
||||
return ResponseEntity.ok(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -191,6 +191,12 @@ public class CertSignController {
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
privateKeyFile =
|
||||
validateFilePresent(
|
||||
privateKeyFile, "PEM private key", "private key file is required");
|
||||
certFile =
|
||||
validateFilePresent(
|
||||
certFile, "PEM certificate", "certificate file is required");
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(null);
|
||||
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
|
||||
@@ -200,10 +206,16 @@ public class CertSignController {
|
||||
break;
|
||||
case "PKCS12":
|
||||
case "PFX":
|
||||
p12File =
|
||||
validateFilePresent(
|
||||
p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required");
|
||||
ks = KeyStore.getInstance("PKCS12");
|
||||
ks.load(p12File.getInputStream(), password.toCharArray());
|
||||
break;
|
||||
case "JKS":
|
||||
jksfile =
|
||||
validateFilePresent(
|
||||
jksfile, "JKS keystore", "JKS keystore file is required");
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(jksfile.getInputStream(), password.toCharArray());
|
||||
break;
|
||||
@@ -251,6 +263,17 @@ public class CertSignController {
|
||||
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf"));
|
||||
}
|
||||
|
||||
private MultipartFile validateFilePresent(
|
||||
MultipartFile file, String argumentName, String errorDescription) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
"Invalid argument: {0}",
|
||||
argumentName + " - " + errorDescription);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
|
||||
throws IOException, OperatorCreationException, PKCSException {
|
||||
try (PEMParser pemParser =
|
||||
|
||||
+45
-4
@@ -3,9 +3,14 @@ package stirling.software.SPDF.controller.web;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
@@ -14,6 +19,11 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@@ -22,24 +32,44 @@ public class ReactRoutingController {
|
||||
|
||||
private String cachedIndexHtml;
|
||||
private boolean indexHtmlExists = false;
|
||||
private boolean useExternalIndexHtml = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// Only cache if index.html exists (production builds)
|
||||
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
log.info("Using custom index.html from: {}", externalIndexPath);
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = true;
|
||||
return;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load custom index.html, falling back to classpath", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to classpath index.html
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
if (resource.exists()) {
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
} catch (IOException e) {
|
||||
// Failed to cache, will process on each request
|
||||
log.warn("Failed to cache index.html", e);
|
||||
this.indexHtmlExists = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String processIndexHtml() throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
Resource resource = getIndexHtmlResource();
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
@@ -62,6 +92,17 @@ public class ReactRoutingController {
|
||||
}
|
||||
}
|
||||
|
||||
private Resource getIndexHtmlResource() throws IOException {
|
||||
// Check external location first
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
return new FileSystemResource(externalIndexPath.toFile());
|
||||
}
|
||||
|
||||
// Fall back to classpath
|
||||
return new ClassPathResource("static/index.html");
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
@@ -74,13 +115,13 @@ public class ReactRoutingController {
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.SPDF.exception;
|
||||
|
||||
public class CacheUnavailableException extends RuntimeException {
|
||||
|
||||
public CacheUnavailableException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -45,4 +45,26 @@ public class OptimizePdfRequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
defaultValue = "false")
|
||||
private Boolean grayscale = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Whether to convert images to high-contrast line art using ImageMagick. Default is false.",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private Boolean lineArt = false;
|
||||
|
||||
@Schema(
|
||||
description = "Threshold to use for line art conversion (0-100).",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "55")
|
||||
private Double lineArtThreshold = 55d;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Edge detection strength to use for line art conversion (1-3). This maps to"
|
||||
+ " ImageMagick's -edge radius.",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "1",
|
||||
allowableValues = {"1", "2", "3"})
|
||||
private Integer lineArtEdgeLevel = 1;
|
||||
}
|
||||
|
||||
+279
-31
@@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.apache.pdfbox.util.DateConverter;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -144,15 +143,23 @@ public class PdfJsonConversionService {
|
||||
private final PdfJsonFontService fontService;
|
||||
private final Type3FontConversionService type3FontConversionService;
|
||||
private final Type3GlyphExtractor type3GlyphExtractor;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
private final Map<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>();
|
||||
private final Map<String, Set<Integer>> type3GlyphCoverageCache = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${stirling.pdf.json.font-normalization.enabled:true}")
|
||||
private boolean fontNormalizationEnabled;
|
||||
private long cacheMaxBytes;
|
||||
private int cacheMaxPercent;
|
||||
|
||||
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
|
||||
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>();
|
||||
|
||||
private final java.util.LinkedHashMap<String, CachedPdfDocument> lruCache =
|
||||
new java.util.LinkedHashMap<>(16, 0.75f, true);
|
||||
private final Object cacheLock = new Object();
|
||||
private volatile long currentCacheBytes = 0L;
|
||||
private volatile long cacheBudgetBytes = -1L;
|
||||
|
||||
private volatile boolean ghostscriptAvailable;
|
||||
|
||||
private static final float FLOAT_EPSILON = 0.0001f;
|
||||
@@ -161,7 +168,23 @@ public class PdfJsonConversionService {
|
||||
|
||||
@PostConstruct
|
||||
private void initializeToolAvailability() {
|
||||
loadConfigurationFromProperties();
|
||||
initializeGhostscriptAvailability();
|
||||
initializeCacheBudget();
|
||||
}
|
||||
|
||||
private void loadConfigurationFromProperties() {
|
||||
stirling.software.common.model.ApplicationProperties.PdfEditor cfg =
|
||||
applicationProperties.getPdfEditor();
|
||||
if (cfg != null) {
|
||||
fontNormalizationEnabled = cfg.getFontNormalization().isEnabled();
|
||||
cacheMaxBytes = cfg.getCache().getMaxBytes();
|
||||
cacheMaxPercent = cfg.getCache().getMaxPercent();
|
||||
} else {
|
||||
fontNormalizationEnabled = false;
|
||||
cacheMaxBytes = -1;
|
||||
cacheMaxPercent = 20;
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeGhostscriptAvailability() {
|
||||
@@ -202,6 +225,25 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeCacheBudget() {
|
||||
long effective = -1L;
|
||||
if (cacheMaxBytes > 0) {
|
||||
effective = cacheMaxBytes;
|
||||
} else if (cacheMaxPercent > 0) {
|
||||
long maxMem = Runtime.getRuntime().maxMemory();
|
||||
effective = Math.max(0L, (maxMem * cacheMaxPercent) / 100);
|
||||
}
|
||||
cacheBudgetBytes = effective;
|
||||
if (cacheBudgetBytes > 0) {
|
||||
log.info(
|
||||
"PDF JSON cache budget configured: {} bytes (source: {})",
|
||||
cacheBudgetBytes,
|
||||
cacheMaxBytes > 0 ? "max-bytes" : "max-percent");
|
||||
} else {
|
||||
log.info("PDF JSON cache budget: unlimited");
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] convertPdfToJson(MultipartFile file) throws IOException {
|
||||
return convertPdfToJson(file, null, false);
|
||||
}
|
||||
@@ -236,7 +278,10 @@ public class PdfJsonConversionService {
|
||||
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
|
||||
} else {
|
||||
jobId = contextJobId;
|
||||
log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId);
|
||||
log.info(
|
||||
"Starting PDF to JSON conversion, jobId from context: {} (lightweight={})",
|
||||
jobId,
|
||||
lightweight);
|
||||
}
|
||||
|
||||
Consumer<PdfJsonConversionProgress> progress =
|
||||
@@ -318,9 +363,9 @@ public class PdfJsonConversionService {
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
// Only use lazy images for real async jobs where client can access the cache
|
||||
// Synchronous calls with synthetic jobId should do full extraction
|
||||
boolean useLazyImages = totalPages > 5 && isRealJobId;
|
||||
// Always enable lazy mode for real async jobs so cache is available regardless of
|
||||
// page count. Synchronous calls with synthetic jobId still do full extraction.
|
||||
boolean useLazyImages = isRealJobId;
|
||||
Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
|
||||
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
|
||||
log.debug(
|
||||
@@ -403,6 +448,11 @@ public class PdfJsonConversionService {
|
||||
|
||||
// Only cache for real async jobIds, not synthetic synchronous ones
|
||||
if (useLazyImages && isRealJobId) {
|
||||
log.info(
|
||||
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
|
||||
jobId,
|
||||
useLazyImages,
|
||||
isRealJobId);
|
||||
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
|
||||
docMetadata.setMetadata(pdfJson.getMetadata());
|
||||
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
|
||||
@@ -435,16 +485,23 @@ public class PdfJsonConversionService {
|
||||
cachedPdfBytes = Files.readAllBytes(workingPath);
|
||||
}
|
||||
CachedPdfDocument cached =
|
||||
new CachedPdfDocument(
|
||||
cachedPdfBytes, docMetadata, fonts, pageFontResources);
|
||||
documentCache.put(jobId, cached);
|
||||
log.debug(
|
||||
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}",
|
||||
cachedPdfBytes.length,
|
||||
buildCachedDocument(
|
||||
jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources);
|
||||
putCachedDocument(jobId, cached);
|
||||
log.info(
|
||||
"Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})",
|
||||
cached.getPdfSize(),
|
||||
totalPages,
|
||||
fonts.size(),
|
||||
jobId);
|
||||
jobId,
|
||||
cached.isDiskBacked());
|
||||
scheduleDocumentCleanup(jobId);
|
||||
} else {
|
||||
log.warn(
|
||||
"Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
|
||||
useLazyImages,
|
||||
isRealJobId,
|
||||
jobId);
|
||||
}
|
||||
|
||||
if (lightweight) {
|
||||
@@ -2973,6 +3030,139 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache helpers
|
||||
private CachedPdfDocument buildCachedDocument(
|
||||
String jobId,
|
||||
byte[] pdfBytes,
|
||||
PdfJsonDocumentMetadata metadata,
|
||||
Map<String, PdfJsonFont> fonts,
|
||||
Map<Integer, Map<PDFont, String>> pageFontResources)
|
||||
throws IOException {
|
||||
if (pdfBytes == null) {
|
||||
throw new IllegalArgumentException("pdfBytes must not be null");
|
||||
}
|
||||
long budget = cacheBudgetBytes;
|
||||
// If single document is larger than budget, spill straight to disk
|
||||
if (budget > 0 && pdfBytes.length > budget) {
|
||||
TempFile tempFile = new TempFile(tempFileManager, ".pdfjsoncache");
|
||||
Files.write(tempFile.getPath(), pdfBytes);
|
||||
log.debug(
|
||||
"Cached PDF spilled to disk ({} bytes exceeds budget {}) for jobId {}",
|
||||
pdfBytes.length,
|
||||
budget,
|
||||
jobId);
|
||||
return new CachedPdfDocument(
|
||||
null, tempFile, pdfBytes.length, metadata, fonts, pageFontResources);
|
||||
}
|
||||
return new CachedPdfDocument(
|
||||
pdfBytes, null, pdfBytes.length, metadata, fonts, pageFontResources);
|
||||
}
|
||||
|
||||
private void putCachedDocument(String jobId, CachedPdfDocument cached) {
|
||||
synchronized (cacheLock) {
|
||||
CachedPdfDocument existing = documentCache.put(jobId, cached);
|
||||
if (existing != null) {
|
||||
lruCache.remove(jobId);
|
||||
currentCacheBytes = Math.max(0L, currentCacheBytes - existing.getInMemorySize());
|
||||
existing.close();
|
||||
}
|
||||
lruCache.put(jobId, cached);
|
||||
currentCacheBytes += cached.getInMemorySize();
|
||||
enforceCacheBudget();
|
||||
}
|
||||
}
|
||||
|
||||
private CachedPdfDocument getCachedDocument(String jobId) {
|
||||
synchronized (cacheLock) {
|
||||
CachedPdfDocument cached = documentCache.get(jobId);
|
||||
if (cached != null) {
|
||||
lruCache.remove(jobId);
|
||||
lruCache.put(jobId, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
private void enforceCacheBudget() {
|
||||
if (cacheBudgetBytes <= 0) {
|
||||
return;
|
||||
}
|
||||
// Must be called under cacheLock
|
||||
java.util.Iterator<java.util.Map.Entry<String, CachedPdfDocument>> it =
|
||||
lruCache.entrySet().iterator();
|
||||
while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) {
|
||||
java.util.Map.Entry<String, CachedPdfDocument> entry = it.next();
|
||||
it.remove();
|
||||
CachedPdfDocument removed = entry.getValue();
|
||||
documentCache.remove(entry.getKey(), removed);
|
||||
currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
|
||||
removed.close();
|
||||
log.warn(
|
||||
"Evicted cached PDF for jobId {} to enforce cache budget (budget={} bytes, current={} bytes)",
|
||||
entry.getKey(),
|
||||
cacheBudgetBytes,
|
||||
currentCacheBytes);
|
||||
}
|
||||
if (currentCacheBytes > cacheBudgetBytes && !lruCache.isEmpty()) {
|
||||
// Spill the most recently used large entry to disk
|
||||
String key =
|
||||
lruCache.entrySet().stream()
|
||||
.reduce((first, second) -> second)
|
||||
.map(java.util.Map.Entry::getKey)
|
||||
.orElse(null);
|
||||
if (key != null) {
|
||||
CachedPdfDocument doc = lruCache.get(key);
|
||||
if (doc != null && doc.getInMemorySize() > 0) {
|
||||
try {
|
||||
CachedPdfDocument diskDoc =
|
||||
buildCachedDocument(
|
||||
key,
|
||||
doc.getPdfBytes(),
|
||||
doc.getMetadata(),
|
||||
doc.getFonts(),
|
||||
doc.getPageFontResources());
|
||||
lruCache.put(key, diskDoc);
|
||||
documentCache.put(key, diskDoc);
|
||||
currentCacheBytes =
|
||||
Math.max(0L, currentCacheBytes - doc.getInMemorySize())
|
||||
+ diskDoc.getInMemorySize();
|
||||
doc.close();
|
||||
log.debug("Spilled cached PDF for jobId {} to disk to satisfy budget", key);
|
||||
} catch (IOException ex) {
|
||||
log.warn(
|
||||
"Failed to spill cached PDF for jobId {} to disk: {}",
|
||||
key,
|
||||
ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeCachedDocument(String jobId) {
|
||||
log.warn(
|
||||
"removeCachedDocument called for jobId: {} [CALLER: {}]",
|
||||
jobId,
|
||||
Thread.currentThread().getStackTrace()[2].toString());
|
||||
CachedPdfDocument removed = null;
|
||||
synchronized (cacheLock) {
|
||||
removed = documentCache.remove(jobId);
|
||||
if (removed != null) {
|
||||
lruCache.remove(jobId);
|
||||
currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize());
|
||||
log.warn(
|
||||
"Removed cached document for jobId: {} (size={} bytes)",
|
||||
jobId,
|
||||
removed.getInMemorySize());
|
||||
} else {
|
||||
log.warn("Attempted to remove jobId: {} but it was not in cache", jobId);
|
||||
}
|
||||
}
|
||||
if (removed != null) {
|
||||
removed.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element)
|
||||
throws IOException {
|
||||
if (element.getCharacterSpacing() != null) {
|
||||
@@ -5311,6 +5501,8 @@ public class PdfJsonConversionService {
|
||||
*/
|
||||
private static class CachedPdfDocument {
|
||||
private final byte[] pdfBytes;
|
||||
private final TempFile pdfTempFile;
|
||||
private final long pdfSize;
|
||||
private final PdfJsonDocumentMetadata metadata;
|
||||
private final Map<String, PdfJsonFont> fonts; // Font map with UIDs for consistency
|
||||
private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources
|
||||
@@ -5318,10 +5510,14 @@ public class PdfJsonConversionService {
|
||||
|
||||
public CachedPdfDocument(
|
||||
byte[] pdfBytes,
|
||||
TempFile pdfTempFile,
|
||||
long pdfSize,
|
||||
PdfJsonDocumentMetadata metadata,
|
||||
Map<String, PdfJsonFont> fonts,
|
||||
Map<Integer, Map<PDFont, String>> pageFontResources) {
|
||||
this.pdfBytes = pdfBytes;
|
||||
this.pdfTempFile = pdfTempFile;
|
||||
this.pdfSize = pdfSize;
|
||||
this.metadata = metadata;
|
||||
// Create defensive copies to prevent mutation of shared maps
|
||||
this.fonts =
|
||||
@@ -5336,8 +5532,14 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
// Getters return defensive copies to prevent external mutation
|
||||
public byte[] getPdfBytes() {
|
||||
return pdfBytes;
|
||||
public byte[] getPdfBytes() throws IOException {
|
||||
if (pdfBytes != null) {
|
||||
return pdfBytes;
|
||||
}
|
||||
if (pdfTempFile != null) {
|
||||
return Files.readAllBytes(pdfTempFile.getPath());
|
||||
}
|
||||
throw new IOException("Cached PDF backing missing");
|
||||
}
|
||||
|
||||
public PdfJsonDocumentMetadata getMetadata() {
|
||||
@@ -5352,6 +5554,18 @@ public class PdfJsonConversionService {
|
||||
return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources);
|
||||
}
|
||||
|
||||
public long getPdfSize() {
|
||||
return pdfSize;
|
||||
}
|
||||
|
||||
public long getInMemorySize() {
|
||||
return pdfBytes != null ? pdfBytes.length : 0L;
|
||||
}
|
||||
|
||||
public boolean isDiskBacked() {
|
||||
return pdfBytes == null && pdfTempFile != null;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
@@ -5363,7 +5577,19 @@ public class PdfJsonConversionService {
|
||||
public CachedPdfDocument withUpdatedFonts(
|
||||
byte[] nextBytes, Map<String, PdfJsonFont> nextFonts) {
|
||||
Map<String, PdfJsonFont> fontsToUse = nextFonts != null ? nextFonts : this.fonts;
|
||||
return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources);
|
||||
return new CachedPdfDocument(
|
||||
nextBytes,
|
||||
null,
|
||||
nextBytes != null ? nextBytes.length : 0,
|
||||
metadata,
|
||||
fontsToUse,
|
||||
pageFontResources);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (pdfTempFile != null) {
|
||||
pdfTempFile.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5444,14 +5670,15 @@ public class PdfJsonConversionService {
|
||||
// Cache PDF bytes, metadata, and fonts for lazy page loading
|
||||
if (jobId != null) {
|
||||
CachedPdfDocument cached =
|
||||
new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources);
|
||||
documentCache.put(jobId, cached);
|
||||
buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources);
|
||||
putCachedDocument(jobId, cached);
|
||||
log.debug(
|
||||
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}",
|
||||
pdfBytes.length,
|
||||
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})",
|
||||
cached.getPdfSize(),
|
||||
totalPages,
|
||||
fonts.size(),
|
||||
jobId);
|
||||
jobId,
|
||||
cached.isDiskBacked());
|
||||
|
||||
// Schedule cleanup after 30 minutes
|
||||
scheduleDocumentCleanup(jobId);
|
||||
@@ -5466,9 +5693,10 @@ public class PdfJsonConversionService {
|
||||
|
||||
/** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */
|
||||
public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException {
|
||||
CachedPdfDocument cached = documentCache.get(jobId);
|
||||
CachedPdfDocument cached = getCachedDocument(jobId);
|
||||
if (cached == null) {
|
||||
throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
|
||||
throw new stirling.software.SPDF.exception.CacheUnavailableException(
|
||||
"No cached document found for jobId: " + jobId);
|
||||
}
|
||||
|
||||
int pageIndex = pageNumber - 1;
|
||||
@@ -5480,8 +5708,8 @@ public class PdfJsonConversionService {
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})",
|
||||
cached.getPdfBytes().length,
|
||||
"Loading PDF from {} to extract page {} (jobId: {})",
|
||||
cached.isDiskBacked() ? "disk cache" : "memory cache",
|
||||
pageNumber,
|
||||
jobId);
|
||||
|
||||
@@ -5627,10 +5855,21 @@ public class PdfJsonConversionService {
|
||||
if (jobId == null || jobId.isBlank()) {
|
||||
throw new IllegalArgumentException("jobId is required for incremental export");
|
||||
}
|
||||
CachedPdfDocument cached = documentCache.get(jobId);
|
||||
log.info("Looking up cache for jobId: {}", jobId);
|
||||
CachedPdfDocument cached = getCachedDocument(jobId);
|
||||
if (cached == null) {
|
||||
throw new IllegalArgumentException("No cached document available for jobId: " + jobId);
|
||||
log.error(
|
||||
"Cache not found for jobId: {}. Available cache keys: {}",
|
||||
jobId,
|
||||
documentCache.keySet());
|
||||
throw new stirling.software.SPDF.exception.CacheUnavailableException(
|
||||
"No cached document available for jobId: " + jobId);
|
||||
}
|
||||
log.info(
|
||||
"Found cached document for jobId: {} (size={}, diskBacked={})",
|
||||
jobId,
|
||||
cached.getPdfSize(),
|
||||
cached.isDiskBacked());
|
||||
if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) {
|
||||
log.debug(
|
||||
"Incremental export requested with no page updates; returning cached PDF for jobId {}",
|
||||
@@ -5709,7 +5948,14 @@ public class PdfJsonConversionService {
|
||||
document.save(baos);
|
||||
byte[] updatedBytes = baos.toByteArray();
|
||||
|
||||
documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts));
|
||||
CachedPdfDocument updated =
|
||||
buildCachedDocument(
|
||||
jobId,
|
||||
updatedBytes,
|
||||
cached.getMetadata(),
|
||||
mergedFonts,
|
||||
cached.getPageFontResources());
|
||||
putCachedDocument(jobId, updated);
|
||||
|
||||
// Clear Type3 cache entries for this incremental update
|
||||
clearType3CacheEntriesForJob(updateJobId);
|
||||
@@ -5724,11 +5970,13 @@ public class PdfJsonConversionService {
|
||||
|
||||
/** Clears a cached document. */
|
||||
public void clearCachedDocument(String jobId) {
|
||||
CachedPdfDocument cached = documentCache.remove(jobId);
|
||||
CachedPdfDocument cached = getCachedDocument(jobId);
|
||||
removeCachedDocument(jobId);
|
||||
if (cached != null) {
|
||||
log.debug(
|
||||
"Removed cached PDF bytes ({} bytes) for jobId: {}",
|
||||
cached.getPdfBytes().length,
|
||||
"Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}",
|
||||
cached.getPdfSize(),
|
||||
cached.isDiskBacked(),
|
||||
jobId);
|
||||
}
|
||||
|
||||
|
||||
@@ -312,12 +312,29 @@ public class PdfJsonFallbackFontService {
|
||||
"ttf")));
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
|
||||
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
|
||||
private String legacyFallbackFontLocation;
|
||||
|
||||
private String fallbackFontLocation;
|
||||
|
||||
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>();
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
private void loadConfig() {
|
||||
String configured = null;
|
||||
if (applicationProperties.getPdfEditor() != null) {
|
||||
configured = applicationProperties.getPdfEditor().getFallbackFont();
|
||||
}
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
fallbackFontLocation = configured;
|
||||
} else {
|
||||
fallbackFontLocation = legacyFallbackFontLocation;
|
||||
}
|
||||
log.info("Using fallback font location: {}", fallbackFontLocation);
|
||||
}
|
||||
|
||||
public PdfJsonFont buildFallbackFontModel() throws IOException {
|
||||
return buildFallbackFontModel(FALLBACK_FONT_ID);
|
||||
}
|
||||
|
||||
+20
-10
@@ -5,7 +5,6 @@ import java.nio.file.Files;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager;
|
||||
public class PdfJsonFontService {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
|
||||
@Getter
|
||||
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
|
||||
private boolean cffConversionEnabled;
|
||||
@Getter private boolean cffConversionEnabled;
|
||||
|
||||
@Getter
|
||||
@Value("${stirling.pdf.json.cff-converter.method:python}")
|
||||
private String cffConverterMethod;
|
||||
@Getter private String cffConverterMethod;
|
||||
|
||||
@Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
|
||||
private String pythonCommand;
|
||||
|
||||
@Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
|
||||
private String pythonScript;
|
||||
|
||||
@Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
|
||||
private String fontforgeCommand;
|
||||
|
||||
private volatile boolean pythonCffConverterAvailable;
|
||||
@@ -48,6 +41,7 @@ public class PdfJsonFontService {
|
||||
|
||||
@PostConstruct
|
||||
private void initialiseCffConverterAvailability() {
|
||||
loadConfiguration();
|
||||
if (!cffConversionEnabled) {
|
||||
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
|
||||
pythonCffConverterAvailable = false;
|
||||
@@ -77,6 +71,22 @@ public class PdfJsonFontService {
|
||||
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod);
|
||||
}
|
||||
|
||||
private void loadConfiguration() {
|
||||
if (applicationProperties.getPdfEditor() != null
|
||||
&& applicationProperties.getPdfEditor().getCffConverter() != null) {
|
||||
var cfg = applicationProperties.getPdfEditor().getCffConverter();
|
||||
this.cffConversionEnabled = cfg.isEnabled();
|
||||
this.cffConverterMethod = cfg.getMethod();
|
||||
this.pythonCommand = cfg.getPythonCommand();
|
||||
this.pythonScript = cfg.getPythonScript();
|
||||
this.fontforgeCommand = cfg.getFontforgeCommand();
|
||||
} else {
|
||||
// Use defaults when config is not available
|
||||
this.cffConversionEnabled = false;
|
||||
log.warn("[FONT-DEBUG] PdfEditor configuration not available, CFF conversion disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
|
||||
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
|
||||
log.warn(
|
||||
|
||||
+14
-2
@@ -2,7 +2,6 @@ package stirling.software.SPDF.service.pdfjson.type3;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl
|
||||
public class Type3LibraryStrategy implements Type3ConversionStrategy {
|
||||
|
||||
private final Type3FontLibrary fontLibrary;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
|
||||
@Value("${stirling.pdf.json.type3.library.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Override
|
||||
@@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
|
||||
return enabled && fontLibrary != null && fontLibrary.isLoaded();
|
||||
}
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
private void loadConfiguration() {
|
||||
if (applicationProperties.getPdfEditor() != null
|
||||
&& applicationProperties.getPdfEditor().getType3() != null
|
||||
&& applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
|
||||
var cfg = applicationProperties.getPdfEditor().getType3().getLibrary();
|
||||
this.enabled = cfg.isEnabled();
|
||||
} else {
|
||||
this.enabled = false;
|
||||
log.warn("PdfEditor Type3 library configuration not available, disabled");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PdfJsonFontConversionCandidate convert(
|
||||
Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
|
||||
|
||||
+12
-2
@@ -14,7 +14,6 @@ import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType3Font;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -34,8 +33,8 @@ public class Type3FontLibrary {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
|
||||
@Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}")
|
||||
private String indexLocation;
|
||||
|
||||
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
|
||||
@@ -44,6 +43,17 @@ public class Type3FontLibrary {
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void initialise() {
|
||||
if (applicationProperties.getPdfEditor() != null
|
||||
&& applicationProperties.getPdfEditor().getType3() != null
|
||||
&& applicationProperties.getPdfEditor().getType3().getLibrary() != null) {
|
||||
this.indexLocation =
|
||||
applicationProperties.getPdfEditor().getType3().getLibrary().getIndex();
|
||||
} else {
|
||||
log.warn(
|
||||
"[TYPE3] PdfEditor Type3 library configuration not available; Type3 library disabled");
|
||||
entries = List.of();
|
||||
return;
|
||||
}
|
||||
Resource resource = resourceLoader.getResource(indexLocation);
|
||||
if (!resource.exists()) {
|
||||
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
|
||||
|
||||
@@ -58,6 +58,8 @@ security:
|
||||
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
|
||||
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
|
||||
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
|
||||
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
|
||||
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
|
||||
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
|
||||
persistence: true # Set to 'true' to enable JWT key store
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
@@ -126,13 +128,15 @@ system:
|
||||
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
|
||||
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
|
||||
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
|
||||
enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow
|
||||
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
|
||||
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
|
||||
frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL.
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
|
||||
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
|
||||
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
@@ -178,23 +182,6 @@ system:
|
||||
databaseBackup:
|
||||
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
|
||||
|
||||
stirling:
|
||||
pdf:
|
||||
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
|
||||
json:
|
||||
font-normalization:
|
||||
enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
|
||||
cff-converter:
|
||||
enabled: true # Wrap CFF/Type1C fonts as OpenType-CFF for browser compatibility
|
||||
method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
|
||||
python-command: /opt/venv/bin/python3 # Python interpreter path
|
||||
python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
|
||||
fontforge-command: fontforge # Override if FontForge is installed under a different name/path
|
||||
type3:
|
||||
library:
|
||||
enabled: true # Match common Type3 fonts against the built-in library of converted programs
|
||||
index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
|
||||
|
||||
ui:
|
||||
appNameNavbar: '' # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
@@ -223,6 +210,7 @@ processExecutor:
|
||||
weasyPrintSessionLimit: 16
|
||||
installAppSessionLimit: 1
|
||||
calibreSessionLimit: 1
|
||||
imageMagickSessionLimit: 4
|
||||
ghostscriptSessionLimit: 8
|
||||
ocrMyPdfSessionLimit: 2
|
||||
timeoutMinutes: # Process executor timeout in minutes
|
||||
@@ -232,7 +220,26 @@ processExecutor:
|
||||
weasyPrinttimeoutMinutes: 30
|
||||
installApptimeoutMinutes: 60
|
||||
calibretimeoutMinutes: 30
|
||||
imageMagickTimeoutMinutes: 30
|
||||
tesseractTimeoutMinutes: 30
|
||||
qpdfTimeoutMinutes: 30
|
||||
ghostscriptTimeoutMinutes: 30
|
||||
ocrMyPdfTimeoutMinutes: 30
|
||||
|
||||
pdfEditor:
|
||||
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
|
||||
cache:
|
||||
max-bytes: -1 # Max in-memory cache size in bytes; -1 disables byte cap
|
||||
max-percent: 20 # Max in-memory cache as % of JVM max; used when max-bytes <= 0
|
||||
font-normalization:
|
||||
enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts.
|
||||
cff-converter:
|
||||
enabled: true # Wrap CFF/Type1CFF fonts as OpenType-CFF for browser compatibility
|
||||
method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts)
|
||||
python-command: /opt/venv/bin/python3 # Python interpreter path
|
||||
python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script
|
||||
fontforge-command: fontforge # Override if FontForge is installed under a different name/path
|
||||
type3:
|
||||
library:
|
||||
enabled: true # Match common Type3 fonts against the built-in library of converted programs
|
||||
index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:)
|
||||
|
||||
+29
-2
@@ -1,9 +1,10 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
@@ -107,7 +108,8 @@ class CertSignControllerTest {
|
||||
derCertBytes = baos.toByteArray();
|
||||
}
|
||||
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
lenient()
|
||||
.when(pdfDocumentFactory.load(any(MultipartFile.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
MultipartFile file = invocation.getArgument(0);
|
||||
@@ -167,6 +169,31 @@ class CertSignControllerTest {
|
||||
assertTrue(response.getBody().length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSignPdfWithMissingPkcs12FileThrowsError() {
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes);
|
||||
|
||||
SignPDFWithCertRequest request = new SignPDFWithCertRequest();
|
||||
request.setFileInput(pdfFile);
|
||||
request.setCertType("PFX");
|
||||
request.setPassword("password");
|
||||
request.setShowSignature(false);
|
||||
request.setReason("test");
|
||||
request.setLocation("test");
|
||||
request.setName("tester");
|
||||
request.setPageNumber(1);
|
||||
request.setShowLogo(false);
|
||||
|
||||
IllegalArgumentException exception =
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> certSignController.signPDFWithCert(request));
|
||||
|
||||
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSignPdfWithJks() throws Exception {
|
||||
MockMultipartFile pdfFile =
|
||||
|
||||
+23
-4
@@ -94,6 +94,22 @@ public class ProprietaryUIDataController {
|
||||
this.auditRepository = auditRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set,
|
||||
* otherwise defaults to http://localhost:8080
|
||||
*/
|
||||
private String getBackendBaseUrl() {
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
|
||||
// If backendUrl is configured, use it
|
||||
if (backendUrl != null && !backendUrl.trim().isEmpty()) {
|
||||
return backendUrl.trim();
|
||||
}
|
||||
|
||||
// For development, default to localhost:8080 (backend port)
|
||||
return "http://localhost:8080";
|
||||
}
|
||||
|
||||
@GetMapping("/audit-dashboard")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@EnterpriseEndpoint
|
||||
@@ -185,14 +201,17 @@ public class ProprietaryUIDataController {
|
||||
}
|
||||
|
||||
SAML2 saml2 = securityProps.getSaml2();
|
||||
if (securityProps.isSaml2Active()
|
||||
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
|
||||
&& applicationProperties.getPremium().isEnabled()) {
|
||||
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
|
||||
String samlIdp = saml2.getProvider();
|
||||
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
|
||||
|
||||
// For SAML, we need to use the backend URL directly, not a relative path
|
||||
// This ensures Spring Security generates the correct ACS URL
|
||||
String backendUrl = getBackendBaseUrl();
|
||||
String fullSamlPath = backendUrl + saml2AuthenticationPath;
|
||||
|
||||
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
|
||||
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
|
||||
providerList.put(fullSamlPath, samlIdp + " (SAML 2)");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||
@@ -34,7 +33,6 @@ import stirling.software.proprietary.service.SignatureService;
|
||||
* authentication and enforces per-user storage limits. All endpoints require authentication
|
||||
* via @PreAuthorize("isAuthenticated()").
|
||||
*/
|
||||
@UserApi
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/proprietary/signatures")
|
||||
|
||||
+1
-3
@@ -120,9 +120,7 @@ public class AccountWebController {
|
||||
|
||||
SAML2 saml2 = securityProps.getSaml2();
|
||||
|
||||
if (securityProps.isSaml2Active()
|
||||
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
|
||||
&& applicationProperties.getPremium().isEnabled()) {
|
||||
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
|
||||
String samlIdp = saml2.getProvider();
|
||||
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
|
||||
|
||||
|
||||
+2
-1
@@ -334,7 +334,8 @@ public class SecurityConfiguration {
|
||||
securityProperties.getSaml2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(
|
||||
new CustomSaml2AuthenticationFailureHandler())
|
||||
.authenticationRequestResolver(
|
||||
|
||||
+4
-1
@@ -244,10 +244,13 @@ public class AuthController {
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
userMap.put(
|
||||
"authenticationType",
|
||||
user.getAuthenticationType()); // Expose authentication type for SSO detection
|
||||
|
||||
// Add metadata for OAuth compatibility
|
||||
Map<String, Object> appMetadata = new HashMap<>();
|
||||
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
|
||||
appMetadata.put("provider", user.getAuthenticationType());
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
|
||||
+40
-17
@@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
@@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
log.warn(
|
||||
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
|
||||
username);
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isSamlEligible(null)) {
|
||||
@@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
log.warn(
|
||||
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
|
||||
username);
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
log.debug(
|
||||
"User {} exists with password but is not SSO user, redirecting to logout",
|
||||
username);
|
||||
response.sendRedirect(
|
||||
contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!userExists || saml2Properties.getBlockRegistration()) {
|
||||
log.debug("Registration blocked for new user: {}", username);
|
||||
response.sendRedirect(
|
||||
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
// Block new users only if: blockRegistration is true OR autoCreateUser is false
|
||||
if (!userExists
|
||||
&& (saml2Properties.getBlockRegistration()
|
||||
|| !saml2Properties.getAutoCreateUser())) {
|
||||
log.debug(
|
||||
"Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})",
|
||||
username,
|
||||
saml2Properties.getBlockRegistration(),
|
||||
saml2Properties.getAutoCreateUser());
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
return;
|
||||
}
|
||||
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
|
||||
response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?maxUsersReached=true");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
String contextPath,
|
||||
String jwt) {
|
||||
String redirectPath = resolveRedirectPath(request, contextPath);
|
||||
String origin =
|
||||
resolveForwardedOrigin(request)
|
||||
.orElseGet(
|
||||
() ->
|
||||
resolveOriginFromReferer(request)
|
||||
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||
String origin = resolveOrigin(request);
|
||||
clearRedirectCookie(response);
|
||||
return origin + redirectPath + "#access_token=" + jwt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config,
|
||||
* then falls back to detecting from request headers.
|
||||
*/
|
||||
private String resolveOrigin(HttpServletRequest request) {
|
||||
// First check if frontendUrl is configured
|
||||
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
|
||||
return configuredFrontendUrl.trim();
|
||||
}
|
||||
|
||||
// Fall back to auto-detection from request headers
|
||||
return resolveForwardedOrigin(request)
|
||||
.orElseGet(
|
||||
() ->
|
||||
resolveOriginFromReferer(request)
|
||||
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||
}
|
||||
|
||||
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||
return extractRedirectPathFromCookie(request)
|
||||
.filter(path -> path.startsWith("/"))
|
||||
|
||||
+65
-8
@@ -41,22 +41,74 @@ public class Saml2Configuration {
|
||||
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
|
||||
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
|
||||
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
|
||||
X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert());
|
||||
|
||||
log.info(
|
||||
"Initializing SAML2 configuration with registration ID: {}",
|
||||
samlConf.getRegistrationId());
|
||||
|
||||
// Load IdP certificate
|
||||
X509Certificate idpCert;
|
||||
try {
|
||||
Resource idpCertResource = samlConf.getIdpCert();
|
||||
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
|
||||
if (!idpCertResource.exists()) {
|
||||
log.error(
|
||||
"SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP certificate file does not exist: "
|
||||
+ idpCertResource.getDescription());
|
||||
}
|
||||
idpCert = CertificateUtils.readCertificate(idpCertResource);
|
||||
log.info(
|
||||
"Successfully loaded IdP certificate. Subject: {}",
|
||||
idpCert.getSubjectX500Principal().getName());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
|
||||
}
|
||||
|
||||
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
|
||||
|
||||
// Load SP private key and certificate
|
||||
Resource privateKeyResource = samlConf.getPrivateKey();
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
Saml2X509Credential signingCredential =
|
||||
new Saml2X509Credential(
|
||||
CertificateUtils.readPrivateKey(privateKeyResource),
|
||||
CertificateUtils.readCertificate(certificateResource),
|
||||
Saml2X509CredentialType.SIGNING);
|
||||
|
||||
log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
|
||||
if (!privateKeyResource.exists()) {
|
||||
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
|
||||
throw new IllegalStateException(
|
||||
"SAML2 SP private key file does not exist: "
|
||||
+ privateKeyResource.getDescription());
|
||||
}
|
||||
|
||||
log.info("Loading SP certificate from: {}", certificateResource.getDescription());
|
||||
if (!certificateResource.exists()) {
|
||||
log.error(
|
||||
"SAML2 SP certificate not found at: {}", certificateResource.getDescription());
|
||||
throw new IllegalStateException(
|
||||
"SAML2 SP certificate file does not exist: "
|
||||
+ certificateResource.getDescription());
|
||||
}
|
||||
|
||||
Saml2X509Credential signingCredential;
|
||||
try {
|
||||
signingCredential =
|
||||
new Saml2X509Credential(
|
||||
CertificateUtils.readPrivateKey(privateKeyResource),
|
||||
CertificateUtils.readCertificate(certificateResource),
|
||||
Saml2X509CredentialType.SIGNING);
|
||||
log.info("Successfully loaded SP credentials");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
|
||||
}
|
||||
RelyingPartyRegistration rp =
|
||||
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
|
||||
.signingX509Credentials(c -> c.add(signingCredential))
|
||||
.entityId(samlConf.getIdpIssuer())
|
||||
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation("http://localhost:8080/login")
|
||||
.singleLogoutServiceResponseLocation("{baseUrl}/login")
|
||||
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
|
||||
.assertionConsumerServiceLocation(
|
||||
"{baseUrl}/login/saml2/sso/{registrationId}")
|
||||
@@ -75,9 +127,14 @@ public class Saml2Configuration {
|
||||
.singleLogoutServiceLocation(
|
||||
samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation(
|
||||
"http://localhost:8080/login")
|
||||
"{baseUrl}/login")
|
||||
.wantAuthnRequestsSigned(true))
|
||||
.build();
|
||||
|
||||
log.info(
|
||||
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
|
||||
samlConf.getRegistrationId(),
|
||||
samlConf.getIdpIssuer());
|
||||
return new InMemoryRelyingPartyRegistrationRepository(rp);
|
||||
}
|
||||
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImageMagickLineArtConversionService implements LineArtConversionService {
|
||||
|
||||
@Override
|
||||
public PDImageXObject convertImageToLineArt(
|
||||
PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel)
|
||||
throws IOException {
|
||||
|
||||
Path inputImage = Files.createTempFile("lineart_image_input_", ".png");
|
||||
Path outputImage = Files.createTempFile("lineart_image_output_", ".tiff");
|
||||
|
||||
try {
|
||||
ImageIO.write(originalImage.getImage(), "png", inputImage.toFile());
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("magick");
|
||||
command.add(inputImage.toString());
|
||||
command.add("-colorspace");
|
||||
command.add("Gray");
|
||||
|
||||
// Edge-aware line art conversion using ImageMagick's built-in operators.
|
||||
// -edge/-negate/-normalize are standard convert options (IM v6+/v7) that
|
||||
// accentuate outlines before thresholding to a bilevel image.
|
||||
command.add("-edge");
|
||||
command.add(String.valueOf(edgeLevel));
|
||||
command.add("-negate");
|
||||
command.add("-normalize");
|
||||
|
||||
command.add("-type");
|
||||
command.add("Bilevel");
|
||||
command.add("-threshold");
|
||||
command.add(String.format(Locale.ROOT, "%.1f%%", threshold));
|
||||
command.add("-compress");
|
||||
command.add("Group4");
|
||||
command.add(outputImage.toString());
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.IMAGEMAGICK)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn(
|
||||
"ImageMagick line art conversion failed with return code: {}",
|
||||
result.getRc());
|
||||
throw new IOException("ImageMagick line art conversion failed");
|
||||
}
|
||||
|
||||
byte[] convertedBytes = Files.readAllBytes(outputImage);
|
||||
return PDImageXObject.createFromByteArray(
|
||||
doc, convertedBytes, originalImage.getCOSObject().toString());
|
||||
} catch (Exception e) {
|
||||
log.warn("ImageMagick line art conversion failed", e);
|
||||
throw new IOException("ImageMagick line art conversion failed", e);
|
||||
} finally {
|
||||
Files.deleteIfExists(inputImage);
|
||||
Files.deleteIfExists(outputImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-35
@@ -177,6 +177,13 @@ public class UserLicenseSettingsService {
|
||||
*/
|
||||
@Transactional
|
||||
public void grandfatherExistingOAuthUsers() {
|
||||
// Only grandfather users if this is a V1→V2 upgrade, not a fresh V2 install
|
||||
Boolean isNewServer = applicationProperties.getAutomaticallyGenerated().getIsNewServer();
|
||||
if (Boolean.TRUE.equals(isNewServer)) {
|
||||
log.info("Fresh V2 installation detected - skipping OAuth user grandfathering");
|
||||
return;
|
||||
}
|
||||
|
||||
UserLicenseSettings settings = getOrCreateSettings();
|
||||
|
||||
// Check if we've already run this migration
|
||||
@@ -348,30 +355,22 @@ public class UserLicenseSettingsService {
|
||||
String username = (user != null) ? user.getUsername() : "<new user>";
|
||||
log.info("OAuth eligibility check for user: {}", username);
|
||||
|
||||
// Grandfathered users always have OAuth access
|
||||
if (user != null && user.isOauthGrandfathered()) {
|
||||
log.debug("User {} is grandfathered for OAuth", user.getUsername());
|
||||
// Check license first - if paying, they're eligible (no need to check grandfathering)
|
||||
boolean hasPaid = hasPaidLicense();
|
||||
if (hasPaid) {
|
||||
log.debug("User {} eligible for OAuth via paid license", username);
|
||||
return true;
|
||||
}
|
||||
|
||||
// todo: remove
|
||||
if (user != null) {
|
||||
log.info(
|
||||
"User {} is NOT grandfathered (isOauthGrandfathered={})",
|
||||
username,
|
||||
user.isOauthGrandfathered());
|
||||
} else {
|
||||
log.info("New user attempting OAuth login - checking license requirement");
|
||||
// No license - check if grandfathered (fallback for V1 users)
|
||||
if (user != null && user.isOauthGrandfathered()) {
|
||||
log.info("User {} eligible for OAuth via grandfathering (no paid license)", username);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users can use OAuth with SERVER or ENTERPRISE license
|
||||
boolean hasPaid = hasPaidLicense();
|
||||
log.info(
|
||||
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
|
||||
hasPaid,
|
||||
username,
|
||||
hasPaid);
|
||||
return hasPaid;
|
||||
// Not grandfathered and no license
|
||||
log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,29 +390,26 @@ public class UserLicenseSettingsService {
|
||||
String username = (user != null) ? user.getUsername() : "<new user>";
|
||||
log.info("SAML2 eligibility check for user: {}", username);
|
||||
|
||||
// Grandfathered users always have SAML access
|
||||
if (user != null && user.isOauthGrandfathered()) {
|
||||
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
|
||||
// Check license first - if paying, they're eligible (no need to check grandfathering)
|
||||
boolean hasEnterprise = hasEnterpriseLicense();
|
||||
if (hasEnterprise) {
|
||||
log.debug("User {} eligible for SAML2 via ENTERPRISE license", username);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
// No license - check if grandfathered (fallback for V1 users)
|
||||
if (user != null && user.isOauthGrandfathered()) {
|
||||
log.info(
|
||||
"User {} is NOT grandfathered (isOauthGrandfathered={})",
|
||||
username,
|
||||
user.isOauthGrandfathered());
|
||||
} else {
|
||||
log.info("New user attempting SAML2 login - checking license requirement");
|
||||
"User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)",
|
||||
username);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users can use SAML only with ENTERPRISE license
|
||||
boolean hasEnterprise = hasEnterpriseLicense();
|
||||
// Not grandfathered and no license
|
||||
log.info(
|
||||
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
|
||||
hasEnterprise,
|
||||
username,
|
||||
hasEnterprise);
|
||||
return hasEnterprise;
|
||||
"User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered",
|
||||
username);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
@@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest {
|
||||
@Mock private UserService userService;
|
||||
@Mock private ApplicationProperties applicationProperties;
|
||||
@Mock private ApplicationProperties.Premium premium;
|
||||
@Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated;
|
||||
@Mock private LicenseKeyChecker licenseKeyChecker;
|
||||
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
|
||||
|
||||
@@ -49,6 +50,8 @@ class UserLicenseSettingsServiceTest {
|
||||
mockSettings.setGrandfatheredUserSignature("80:test-signature");
|
||||
|
||||
when(applicationProperties.getPremium()).thenReturn(premium);
|
||||
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
|
||||
when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server
|
||||
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
|
||||
when(userService.getTotalUsersCount()).thenReturn(80L);
|
||||
when(settingsRepository.save(any(UserLicenseSettings.class)))
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.1.2'
|
||||
version = '2.1.4'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -105,6 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
imagemagick \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF
|
||||
|
||||
@@ -81,6 +81,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
@@ -74,6 +74,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
@@ -99,6 +99,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
@@ -101,6 +101,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
imagemagick \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
|
||||
Generated
+33
@@ -105,6 +105,7 @@
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
@@ -11093,6 +11094,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
|
||||
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pac-proxy-agent": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
|
||||
@@ -14503,6 +14517,25 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.4.tgz",
|
||||
"integrity": "sha512-iCmr4GSw4eSnaB+G8zc2f4dxSuDjbkjwpuBLLGvQYR9IW7rnDzftnUjOH5p4RYR+d4GsiBqXRvzuFhs5bnzVyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"p-map": "^7.0.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"tinyglobby": "^0.2.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-tsconfig-paths": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
|
||||
@@ -6131,8 +6131,8 @@ tags = "text,anmerkung,beschriftung"
|
||||
applySignatures = "Text anwenden"
|
||||
|
||||
[addText.text]
|
||||
name = "Textinhalt"
|
||||
placeholder = "Geben Sie den hinzuzufügenden Text ein"
|
||||
name = "Text"
|
||||
placeholder = "Text eingeben"
|
||||
fontLabel = "Schriftart"
|
||||
fontSizeLabel = "Schriftgröße"
|
||||
fontSizePlaceholder = "Schriftgröße eingeben oder wählen (8-200)"
|
||||
|
||||
@@ -312,10 +312,10 @@ yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO f
|
||||
ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro"
|
||||
|
||||
[analytics]
|
||||
title = "Do you want make Stirling PDF better?"
|
||||
paragraph1 = "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents."
|
||||
title = "Do you want to help make Stirling PDF better?"
|
||||
paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
|
||||
paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
|
||||
learnMore = "Learn more"
|
||||
learnMore = "Learn more about our analytics"
|
||||
enable = "Enable analytics"
|
||||
disable = "Disable analytics"
|
||||
settings = "You can change the settings for analytics in the config/settings.yml file"
|
||||
@@ -340,6 +340,10 @@ advance = "Advanced"
|
||||
edit = "View & Edit"
|
||||
popular = "Popular"
|
||||
|
||||
[footer]
|
||||
discord = "Discord"
|
||||
issues = "GitHub"
|
||||
|
||||
[settings.preferences]
|
||||
title = "Preferences"
|
||||
|
||||
@@ -435,6 +439,25 @@ latestVersion = "Latest Version"
|
||||
checkForUpdates = "Check for Updates"
|
||||
viewDetails = "View Details"
|
||||
|
||||
[settings.security]
|
||||
title = "Security"
|
||||
description = "Update your password to keep your account secure."
|
||||
|
||||
[settings.security.password]
|
||||
subtitle = "Change your password. You will be logged out after updating."
|
||||
required = "All fields are required."
|
||||
mismatch = "New passwords do not match."
|
||||
error = "Unable to update password. Please verify your current password and try again."
|
||||
success = "Password updated successfully. Please sign in again."
|
||||
ssoDisabled = "Password changes are managed by your identity provider."
|
||||
current = "Current password"
|
||||
currentPlaceholder = "Enter your current password"
|
||||
new = "New password"
|
||||
newPlaceholder = "Enter a new password"
|
||||
confirm = "Confirm new password"
|
||||
confirmPlaceholder = "Re-enter your new password"
|
||||
update = "Update password"
|
||||
|
||||
[settings.hotkeys]
|
||||
title = "Keyboard Shortcuts"
|
||||
description = "Customize keyboard shortcuts for quick tool access. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel."
|
||||
@@ -488,11 +511,16 @@ low = "Low"
|
||||
title = "Change Credentials"
|
||||
header = "Update Your Account Details"
|
||||
changePassword = "You are using default login credentials. Please enter a new password"
|
||||
ssoManaged = "Your account is managed by your identity provider."
|
||||
newUsername = "New Username"
|
||||
oldPassword = "Current Password"
|
||||
newPassword = "New Password"
|
||||
confirmNewPassword = "Confirm New Password"
|
||||
submit = "Submit Changes"
|
||||
credsUpdated = "Account updated"
|
||||
description = "Changes saved. Please log in again."
|
||||
error = "Unable to update username. Please verify your password and try again."
|
||||
changeUsername = "Update your username. You will be logged out after updating."
|
||||
|
||||
[account]
|
||||
title = "Account Settings"
|
||||
@@ -3703,6 +3731,16 @@ filesize = "File Size"
|
||||
[compress.grayscale]
|
||||
label = "Apply Grayscale for Compression"
|
||||
|
||||
[compress.lineArt]
|
||||
label = "Convert images to line art"
|
||||
description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
|
||||
unavailable = "ImageMagick is not installed or enabled on this server"
|
||||
detailLevel = "Detail level"
|
||||
edgeEmphasis = "Edge emphasis"
|
||||
edgeLow = "Gentle"
|
||||
edgeMedium = "Balanced"
|
||||
edgeHigh = "Strong"
|
||||
|
||||
[compress.tooltip.header]
|
||||
title = "Compress Settings Overview"
|
||||
|
||||
@@ -3720,6 +3758,10 @@ bullet2 = "Higher values reduce file size"
|
||||
title = "Grayscale"
|
||||
text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents."
|
||||
|
||||
[compress.tooltip.lineArt]
|
||||
title = "Line Art"
|
||||
text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected."
|
||||
|
||||
[compress.error]
|
||||
failed = "An error occurred while compressing the PDF."
|
||||
|
||||
@@ -4038,12 +4080,20 @@ settings = "Settings"
|
||||
adminSettings = "Admin Settings"
|
||||
allTools = "Tools"
|
||||
reader = "Reader"
|
||||
tours = "Tours"
|
||||
showMeAround = "Show me around"
|
||||
|
||||
[quickAccess.toursTooltip]
|
||||
admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour."
|
||||
user = "Watch walkthroughs here: Tools tour and the New V2 layout tour."
|
||||
|
||||
[quickAccess.helpMenu]
|
||||
toolsTour = "Tools Tour"
|
||||
toolsTourDesc = "Learn what the tools can do"
|
||||
adminTour = "Admin Tour"
|
||||
adminTourDesc = "Explore admin settings & features"
|
||||
whatsNewTour = "See what's new in V2"
|
||||
whatsNewTourDesc = "Tour the updated layout"
|
||||
|
||||
[admin]
|
||||
error = "Error"
|
||||
@@ -5070,6 +5120,7 @@ loading = "Loading..."
|
||||
back = "Back"
|
||||
continue = "Continue"
|
||||
error = "Error"
|
||||
save = "Save"
|
||||
|
||||
[config.overview]
|
||||
title = "Application Configuration"
|
||||
@@ -5236,6 +5287,16 @@ finish = "Finish"
|
||||
startTour = "Start Tour"
|
||||
startTourDescription = "Take a guided tour of Stirling PDF's key features"
|
||||
|
||||
[onboarding.whatsNew]
|
||||
quickAccess = "Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours."
|
||||
leftPanel = "The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly."
|
||||
fileUpload = "Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace."
|
||||
rightRail = "The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results."
|
||||
topBar = "The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>."
|
||||
pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages."
|
||||
activeFilesView = "Use Active Files to see everything you have open and pick what to work on."
|
||||
wrapUp = "That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour."
|
||||
|
||||
[onboarding.welcomeModal]
|
||||
title = "Welcome to Stirling PDF!"
|
||||
description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
|
||||
@@ -5256,6 +5317,10 @@ download = "Download →"
|
||||
showMeAround = "Show me around"
|
||||
skipTheTour = "Skip the tour"
|
||||
|
||||
[onboarding.tourOverview]
|
||||
title = "Tour Overview"
|
||||
body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
|
||||
|
||||
[onboarding.serverLicense]
|
||||
skip = "Skip for now"
|
||||
seePlans = "See Plans →"
|
||||
@@ -5794,6 +5859,8 @@ notAvailable = "Audit system not available"
|
||||
notAvailableMessage = "The audit system is not configured or not available."
|
||||
disabled = "Audit logging is disabled"
|
||||
disabledMessage = "Enable audit logging in your application configuration to track system events."
|
||||
enterpriseRequired = "Enterprise License Required"
|
||||
enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics."
|
||||
|
||||
[audit.error]
|
||||
title = "Error loading audit system"
|
||||
|
||||
Generated
+93
@@ -589,6 +589,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -716,6 +736,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -908,6 +934,15 @@ dependencies = [
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
@@ -1626,6 +1661,12 @@ dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
@@ -2820,6 +2861,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3651,6 +3702,16 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.39.0"
|
||||
@@ -4256,6 +4317,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-log",
|
||||
@@ -4615,6 +4677,27 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.4"
|
||||
@@ -4735,6 +4818,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -4954,6 +5038,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
|
||||
@@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.1.0"
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-http = "2.4.4"
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
|
||||
tauri-plugin-store = "2.1.0"
|
||||
tauri-plugin-opener = "2.0.0"
|
||||
tauri-plugin-deep-link = "2.4.5"
|
||||
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
||||
tokio = { version = "1.0", features = ["time", "sync"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
},
|
||||
"opener:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
|
||||
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
|
||||
|
||||
mod utils;
|
||||
mod commands;
|
||||
@@ -28,6 +28,17 @@ use commands::{
|
||||
};
|
||||
use state::connection_state::AppConnectionState;
|
||||
use utils::{add_log, get_tauri_logs};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
fn dispatch_deep_link(app: &AppHandle, url: &str) {
|
||||
add_log(format!("🔗 Dispatching deep link: {}", url));
|
||||
let _ = app.emit("deep-link", url.to_string());
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -42,6 +53,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.manage(AppConnectionState::default())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
// This callback runs when a second instance tries to start
|
||||
@@ -78,6 +90,29 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let app_handle = app.handle();
|
||||
// On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
if let Err(err) = app_handle.deep_link().register_all() {
|
||||
add_log(format!("⚠️ Failed to register deep link handler: {}", err));
|
||||
}
|
||||
|
||||
if let Ok(Some(urls)) = app_handle.deep_link().get_current() {
|
||||
let initial_handle = app_handle.clone();
|
||||
for url in urls {
|
||||
dispatch_deep_link(&initial_handle, url.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let event_app_handle = app_handle.clone();
|
||||
app_handle.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
dispatch_deep_link(&event_app_handle, url.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start backend immediately, non-blocking
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
|
||||
@@ -1,74 +1,89 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.0.0",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev -- --mode desktop",
|
||||
"beforeBuildCommand": "npm run build -- --mode desktop"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "dmg", "app", "msi"],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/16x16.png",
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/192x192.png"
|
||||
],
|
||||
"resources": [
|
||||
"libs/*.jar",
|
||||
"runtime/jre/**/*"
|
||||
],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["pdf"],
|
||||
"name": "PDF Document",
|
||||
"description": "Open PDF files with Stirling-PDF",
|
||||
"role": "Editor",
|
||||
"mimeType": "application/pdf"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.1.3",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev -- --mode desktop",
|
||||
"beforeBuildCommand": "npm run build -- --mode desktop"
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
"dmg",
|
||||
"app",
|
||||
"msi"
|
||||
],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/16x16.png",
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/192x192.png"
|
||||
],
|
||||
"resources": [
|
||||
"libs/*.jar",
|
||||
"runtime/jre/**/*"
|
||||
],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": [
|
||||
"pdf"
|
||||
],
|
||||
"name": "PDF Document",
|
||||
"description": "Open PDF files with Stirling-PDF",
|
||||
"role": "Editor",
|
||||
"mimeType": "application/pdf"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"stirlingpdf"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
@@ -13,8 +12,12 @@ interface TextInputWithFontProps {
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
fontLabel: string;
|
||||
fontSizeLabel: string;
|
||||
fontSizePlaceholder: string;
|
||||
colorLabel?: string;
|
||||
onAnyChange?: () => void;
|
||||
}
|
||||
|
||||
@@ -30,9 +33,12 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder,
|
||||
fontLabel,
|
||||
fontSizeLabel,
|
||||
fontSizePlaceholder,
|
||||
colorLabel,
|
||||
onAnyChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
@@ -66,8 +72,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={label || t('sign.text.name', 'Signer name')}
|
||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
onTextChange(e.target.value);
|
||||
@@ -79,7 +85,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label={t('sign.text.fontLabel', 'Font')}
|
||||
label={fontLabel}
|
||||
value={fontFamily}
|
||||
onChange={(value) => {
|
||||
onFontFamilyChange(value || 'Helvetica');
|
||||
@@ -107,8 +113,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label={t('sign.text.fontSizeLabel', 'Font size')}
|
||||
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
|
||||
label={fontSizeLabel}
|
||||
placeholder={fontSizePlaceholder}
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
@@ -155,7 +161,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
{onTextColorChange && (
|
||||
<Box>
|
||||
<TextInput
|
||||
label={t('sign.text.colorLabel', 'Text colour')}
|
||||
label={colorLabel}
|
||||
value={colorInput}
|
||||
placeholder="#000000"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
||||
import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont';
|
||||
|
||||
interface TextToolProps {
|
||||
onTextChange?: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TextTool: React.FC<TextToolProps> = ({
|
||||
onTextChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [fontSize, setFontSize] = useState(16);
|
||||
const [fontFamily, setFontFamily] = useState('Helvetica');
|
||||
|
||||
const handleTextChange = (newText: string) => {
|
||||
setText(newText);
|
||||
onTextChange?.(newText);
|
||||
};
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
if (data) {
|
||||
onTextChange?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableTextInput: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Text"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={handleSignatureDataChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInputWithFont
|
||||
text={text}
|
||||
onTextChange={handleTextChange}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={setFontSize}
|
||||
fontFamily={fontFamily}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
disabled={disabled}
|
||||
label="Text Content"
|
||||
placeholder="Enter text to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
) {
|
||||
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
||||
}
|
||||
|
||||
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? '';
|
||||
if (!label) return '';
|
||||
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split('.').pop() || label;
|
||||
return t(label, fallback);
|
||||
@@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { isAuthRoute } from '@app/constants/routes';
|
||||
import { dispatchTourState } from '@app/constants/events';
|
||||
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
|
||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
|
||||
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
|
||||
@@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
|
||||
export default function Onboarding() {
|
||||
@@ -39,6 +40,11 @@ export default function Onboarding() {
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||
const { config, refetch: refetchConfig } = useAppConfig();
|
||||
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
|
||||
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
|
||||
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
|
||||
|
||||
const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
|
||||
actions.updateRuntimeState({ selectedRole: role });
|
||||
@@ -50,7 +56,34 @@ export default function Onboarding() {
|
||||
window.location.href = '/login';
|
||||
}, [actions]);
|
||||
|
||||
const handleButtonAction = useCallback((action: ButtonAction) => {
|
||||
// Check if we should show analytics modal before onboarding
|
||||
useEffect(() => {
|
||||
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
|
||||
setShowAnalyticsModal(true);
|
||||
}
|
||||
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
||||
|
||||
const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => {
|
||||
if (analyticsLoading) return;
|
||||
setAnalyticsLoading(true);
|
||||
setAnalyticsError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enableAnalytics.toString());
|
||||
|
||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
||||
await refetchConfig();
|
||||
setShowAnalyticsModal(false);
|
||||
setAnalyticsModalDismissed(true);
|
||||
setAnalyticsLoading(false);
|
||||
} catch (error) {
|
||||
setAnalyticsError(error instanceof Error ? error.message : 'Unknown error');
|
||||
setAnalyticsLoading(false);
|
||||
}
|
||||
}, [analyticsLoading, refetchConfig]);
|
||||
|
||||
const handleButtonAction = useCallback(async (action: ButtonAction) => {
|
||||
switch (action) {
|
||||
case 'next':
|
||||
case 'complete-close':
|
||||
@@ -69,35 +102,43 @@ export default function Onboarding() {
|
||||
case 'security-next':
|
||||
if (!runtimeState.selectedRole) return;
|
||||
if (runtimeState.selectedRole !== 'admin') {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||
setIsTourOpen(true);
|
||||
}
|
||||
actions.complete();
|
||||
break;
|
||||
case 'launch-admin':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
|
||||
actions.complete();
|
||||
actions.updateRuntimeState({ tourType: 'admin' });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case 'launch-tools':
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
|
||||
actions.complete();
|
||||
actions.updateRuntimeState({ tourType: 'whatsnew' });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case 'launch-auto': {
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType });
|
||||
actions.complete();
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew';
|
||||
actions.updateRuntimeState({ tourType });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourRequested: false });
|
||||
actions.complete();
|
||||
break;
|
||||
case 'skip-tour':
|
||||
actions.complete();
|
||||
break;
|
||||
case 'see-plans':
|
||||
actions.complete();
|
||||
navigate('/settings/adminPlan');
|
||||
break;
|
||||
case 'enable-analytics':
|
||||
await handleAnalyticsChoice(true);
|
||||
break;
|
||||
case 'disable-analytics':
|
||||
await handleAnalyticsChoice(false);
|
||||
break;
|
||||
}
|
||||
}, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
}, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
|
||||
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||
@@ -117,10 +158,7 @@ export default function Onboarding() {
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
selectCropTool: tourOrch.selectCropTool,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
pinFile: tourOrch.pinFile,
|
||||
modifyCropSettings: tourOrch.modifyCropSettings,
|
||||
executeTool: tourOrch.executeTool,
|
||||
@@ -130,6 +168,24 @@ export default function Onboarding() {
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const whatsNewStepsConfig = useMemo(
|
||||
() => createWhatsNewStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState: tourOrch.saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools: tourOrch.backToAllTools,
|
||||
openFilesModal,
|
||||
loadSampleFile: tourOrch.loadSampleFile,
|
||||
switchToViewer: tourOrch.switchToViewer,
|
||||
switchToPageEditor: tourOrch.switchToPageEditor,
|
||||
switchToActiveFiles: tourOrch.switchToActiveFiles,
|
||||
selectFirstFile: tourOrch.selectFirstFile,
|
||||
},
|
||||
}),
|
||||
[t, tourOrch, closeFilesModal, openFilesModal]
|
||||
);
|
||||
|
||||
const adminStepsConfig = useMemo(
|
||||
() => createAdminStepsConfig({
|
||||
t,
|
||||
@@ -144,21 +200,19 @@ export default function Onboarding() {
|
||||
);
|
||||
|
||||
const tourSteps = useMemo<StepType[]>(() => {
|
||||
const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep?.id === 'tour' && !isTourOpen) {
|
||||
markStepSeen('tour');
|
||||
setIsTourOpen(true);
|
||||
switch (runtimeState.tourType) {
|
||||
case 'admin':
|
||||
return Object.values(adminStepsConfig);
|
||||
case 'whatsnew':
|
||||
return Object.values(whatsNewStepsConfig);
|
||||
default:
|
||||
return Object.values(userStepsConfig);
|
||||
}
|
||||
}, [currentStep, isTourOpen, activeFlow]);
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTourRequested) {
|
||||
actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
|
||||
markStepSeen('tour');
|
||||
actions.updateRuntimeState({ tourType: requestedTourType });
|
||||
setIsTourOpen(true);
|
||||
clearTourRequest();
|
||||
}
|
||||
@@ -176,9 +230,9 @@ export default function Onboarding() {
|
||||
} else {
|
||||
tourOrch.restoreWorkbenchState();
|
||||
}
|
||||
markStepSeen('tour');
|
||||
if (currentStep?.id === 'tour') actions.complete();
|
||||
}, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
|
||||
// Advance to next onboarding step after tour completes
|
||||
actions.complete();
|
||||
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
|
||||
|
||||
const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
|
||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||
@@ -216,8 +270,10 @@ export default function Onboarding() {
|
||||
firstLoginUsername: runtimeState.firstLoginUsername,
|
||||
onPasswordChanged: handlePasswordChanged,
|
||||
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
|
||||
analyticsError,
|
||||
analyticsLoading,
|
||||
});
|
||||
}, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
|
||||
|
||||
const modalSlideCount = useMemo(() => {
|
||||
return activeFlow.filter((step) => step.type === 'modal-slide').length;
|
||||
@@ -237,8 +293,45 @@ export default function Onboarding() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show analytics modal before onboarding if needed
|
||||
if (showAnalyticsModal) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['analytics-choice'];
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
osUrl: '',
|
||||
selectedRole: null,
|
||||
onRoleSelect: () => {},
|
||||
analyticsError,
|
||||
analyticsLoading,
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
slideContent={slideContent}
|
||||
runtimeState={runtimeState}
|
||||
modalSlideCount={1}
|
||||
currentModalSlideIndex={0}
|
||||
onSkip={() => {}} // No skip allowed
|
||||
onAction={async (action) => {
|
||||
if (action === 'enable-analytics') {
|
||||
await handleAnalyticsChoice(true);
|
||||
} else if (action === 'disable-analytics') {
|
||||
await handleAnalyticsChoice(false);
|
||||
}
|
||||
}}
|
||||
allowDismiss={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showLicenseSlide) {
|
||||
const slideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license'];
|
||||
// Remove back button for external license notice
|
||||
const slideDefinition = {
|
||||
...baseSlideDefinition,
|
||||
buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back')
|
||||
};
|
||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: '',
|
||||
@@ -250,7 +343,7 @@ export default function Onboarding() {
|
||||
licenseNotice: effectiveLicenseNotice,
|
||||
loginEnabled: serverExperience.loginEnabled,
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
@@ -271,40 +364,34 @@ export default function Onboarding() {
|
||||
);
|
||||
}
|
||||
|
||||
// Always render the tour component (it controls its own visibility with isOpen)
|
||||
const tourComponent = (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
// If no active onboarding, just show the tour (which may or may not be open)
|
||||
if (isLoading || !isActive || !currentStep) {
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={isTourOpen}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
return tourComponent;
|
||||
}
|
||||
|
||||
// If tour is open, hide the onboarding modal and just show the tour
|
||||
if (isTourOpen) {
|
||||
return tourComponent;
|
||||
}
|
||||
|
||||
// Render the current onboarding step
|
||||
switch (currentStep.type) {
|
||||
case 'tool-prompt':
|
||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||
|
||||
case 'tour':
|
||||
return (
|
||||
<OnboardingTour
|
||||
isOpen={true}
|
||||
tourSteps={tourSteps}
|
||||
tourType={runtimeState.tourType}
|
||||
isRTL={isRTL}
|
||||
t={t}
|
||||
onAdvance={handleAdvanceTour}
|
||||
onClose={handleCloseTour}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'analytics-modal':
|
||||
return <AdminAnalyticsChoiceModal opened={true} onClose={actions.complete} />;
|
||||
|
||||
case 'modal-slide':
|
||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||
return (
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import { Modal, Stack, ActionIcon } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
@@ -28,6 +29,7 @@ interface OnboardingModalSlideProps {
|
||||
currentModalSlideIndex: number;
|
||||
onSkip: () => void;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
allowDismiss?: boolean;
|
||||
}
|
||||
|
||||
export default function OnboardingModalSlide({
|
||||
@@ -38,6 +40,7 @@ export default function OnboardingModalSlide({
|
||||
currentModalSlideIndex,
|
||||
onSkip,
|
||||
onAction,
|
||||
allowDismiss = true,
|
||||
}: OnboardingModalSlideProps) {
|
||||
|
||||
const renderHero = () => {
|
||||
@@ -62,6 +65,9 @@ export default function OnboardingModalSlide({
|
||||
{slideDefinition.hero.type === 'lock' && (
|
||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'analytics' && (
|
||||
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
{slideDefinition.hero.type === 'logo' && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
@@ -75,6 +81,7 @@ export default function OnboardingModalSlide({
|
||||
opened={true}
|
||||
onClose={onSkip}
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={allowDismiss}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
@@ -93,6 +100,31 @@ export default function OnboardingModalSlide({
|
||||
isActive
|
||||
slideKey={slideContent.key}
|
||||
/>
|
||||
{allowDismiss && (
|
||||
<ActionIcon
|
||||
onClick={onSkip}
|
||||
radius="md"
|
||||
size={36}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 10,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
@@ -114,7 +146,9 @@ export default function OnboardingModalSlide({
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
{modalSlideCount > 1 && (
|
||||
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
|
||||
)}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<SlideButtons
|
||||
|
||||
@@ -49,7 +49,7 @@ interface CloseArgs {
|
||||
|
||||
interface OnboardingTourProps {
|
||||
tourSteps: StepType[];
|
||||
tourType: 'admin' | 'tools';
|
||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||
isRTL: boolean;
|
||||
t: TFunction;
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -4,6 +4,8 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide';
|
||||
import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide';
|
||||
import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
|
||||
export type SlideId =
|
||||
@@ -12,9 +14,11 @@ export type SlideId =
|
||||
| 'desktop-install'
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'server-license';
|
||||
| 'server-license'
|
||||
| 'tour-overview'
|
||||
| 'analytics-choice';
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics';
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
@@ -27,7 +31,10 @@ export type ButtonAction =
|
||||
| 'launch-tools'
|
||||
| 'launch-auto'
|
||||
| 'see-plans'
|
||||
| 'skip-to-license';
|
||||
| 'skip-to-license'
|
||||
| 'skip-tour'
|
||||
| 'enable-analytics'
|
||||
| 'disable-analytics';
|
||||
|
||||
export interface FlowState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
@@ -52,6 +59,8 @@ export interface SlideFactoryParams {
|
||||
firstLoginUsername?: string;
|
||||
onPasswordChanged?: () => void;
|
||||
usingDefaultCredentials?: boolean;
|
||||
analyticsError?: string | null;
|
||||
analyticsLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
@@ -79,9 +88,9 @@ export interface SlideDefinition {
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'first-login': {
|
||||
id: 'first-login',
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || '',
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || '',
|
||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||
usingDefaultCredentials: usingDefaultCredentials || false,
|
||||
}),
|
||||
@@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
||||
hero: { type: 'dual-icon' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'license-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'license-close',
|
||||
type: 'button',
|
||||
@@ -212,5 +228,58 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'tour-overview': {
|
||||
id: 'tour-overview',
|
||||
createSlide: () => TourOverviewSlide(),
|
||||
hero: { type: 'rocket' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'tour-overview-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'tour-overview-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'skip-tour',
|
||||
},
|
||||
{
|
||||
key: 'tour-overview-show',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.showMeAround',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'launch-tools',
|
||||
},
|
||||
],
|
||||
},
|
||||
'analytics-choice': {
|
||||
id: 'analytics-choice',
|
||||
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
||||
hero: { type: 'analytics' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'analytics-disable',
|
||||
type: 'button',
|
||||
label: 'no',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'disable-analytics',
|
||||
},
|
||||
{
|
||||
key: 'analytics-enable',
|
||||
type: 'button',
|
||||
label: 'yes',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'enable-analytics',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,21 +5,20 @@ export type OnboardingStepId =
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'tool-layout'
|
||||
| 'tour'
|
||||
| 'tour-overview'
|
||||
| 'server-license'
|
||||
| 'analytics-choice';
|
||||
|
||||
export type OnboardingStepType =
|
||||
| 'modal-slide'
|
||||
| 'tool-prompt'
|
||||
| 'tour'
|
||||
| 'analytics-modal';
|
||||
| 'tool-prompt';
|
||||
|
||||
export interface OnboardingRuntimeState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
tourRequested: boolean;
|
||||
tourType: 'admin' | 'tools';
|
||||
tourType: 'admin' | 'tools' | 'whatsnew';
|
||||
isDesktopApp: boolean;
|
||||
desktopSlideEnabled: boolean;
|
||||
analyticsNotConfigured: boolean;
|
||||
analyticsEnabled: boolean;
|
||||
licenseNotice: {
|
||||
@@ -42,13 +41,13 @@ export interface OnboardingStep {
|
||||
id: OnboardingStepId;
|
||||
type: OnboardingStepType;
|
||||
condition: (ctx: OnboardingConditionContext) => boolean;
|
||||
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
|
||||
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice';
|
||||
}
|
||||
|
||||
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
selectedRole: null,
|
||||
tourRequested: false,
|
||||
tourType: 'tools',
|
||||
tourType: 'whatsnew',
|
||||
isDesktopApp: false,
|
||||
analyticsNotConfigured: false,
|
||||
analyticsEnabled: false,
|
||||
@@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
|
||||
requiresPasswordChange: false,
|
||||
firstLoginUsername: '',
|
||||
usingDefaultCredentials: false,
|
||||
desktopSlideEnabled: true,
|
||||
};
|
||||
|
||||
export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
@@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
slideId: 'welcome',
|
||||
condition: () => true,
|
||||
},
|
||||
{
|
||||
id: 'desktop-install',
|
||||
type: 'modal-slide',
|
||||
slideId: 'desktop-install',
|
||||
condition: (ctx) => !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'security-check',
|
||||
type: 'modal-slide',
|
||||
slideId: 'security-check',
|
||||
condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: 'admin-overview',
|
||||
type: 'modal-slide',
|
||||
@@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
condition: (ctx) => ctx.effectiveIsAdmin,
|
||||
},
|
||||
{
|
||||
id: 'tool-layout',
|
||||
type: 'tool-prompt',
|
||||
condition: () => true,
|
||||
id: 'desktop-install',
|
||||
type: 'modal-slide',
|
||||
slideId: 'desktop-install',
|
||||
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
|
||||
},
|
||||
{
|
||||
id: 'tour',
|
||||
type: 'tour',
|
||||
condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
|
||||
id: 'security-check',
|
||||
type: 'modal-slide',
|
||||
slideId: 'security-check',
|
||||
condition: () => false,
|
||||
},
|
||||
{
|
||||
id: 'tool-layout',
|
||||
type: 'tool-prompt',
|
||||
condition: () => false,
|
||||
},
|
||||
{
|
||||
id: 'tour-overview',
|
||||
type: 'modal-slide',
|
||||
slideId: 'tour-overview',
|
||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
|
||||
},
|
||||
{
|
||||
id: 'server-license',
|
||||
@@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
slideId: 'server-license',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
},
|
||||
{
|
||||
id: 'analytics-choice',
|
||||
type: 'analytics-modal',
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured,
|
||||
},
|
||||
];
|
||||
|
||||
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
|
||||
|
||||
@@ -1,94 +1,71 @@
|
||||
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
|
||||
const STORAGE_PREFIX = 'onboarding';
|
||||
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
|
||||
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
|
||||
|
||||
export function getStorageKey(stepId: OnboardingStepId): string {
|
||||
return `${STORAGE_PREFIX}::${stepId}`;
|
||||
}
|
||||
|
||||
export function hasSeenStep(stepId: OnboardingStepId): boolean {
|
||||
export function isOnboardingCompleted(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
return localStorage.getItem(getStorageKey(stepId)) === 'true';
|
||||
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markStepSeen(stepId: OnboardingStepId): void {
|
||||
export function markOnboardingCompleted(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(stepId), 'true');
|
||||
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true');
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error marking step as seen:', error);
|
||||
console.error('[onboardingStorage] Error marking onboarding as completed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetStepSeen(stepId: OnboardingStepId): void {
|
||||
export function resetOnboardingProgress(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(getStorageKey(stepId));
|
||||
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting step seen:', error);
|
||||
console.error('[onboardingStorage] Error resetting onboarding progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetAllOnboardingProgress(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
export function hasShownToursTooltip(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const prefix = `${STORAGE_PREFIX}::`;
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) keysToRemove.push(key);
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error resetting all onboarding progress:', error);
|
||||
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getOnboardingStorageState(): Record<string, boolean> {
|
||||
const state: Record<string, boolean> = {};
|
||||
ONBOARDING_STEPS.forEach((step) => {
|
||||
state[step.id] = hasSeenStep(step.id);
|
||||
});
|
||||
return state;
|
||||
export function markToursTooltipShown(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true');
|
||||
} catch (error) {
|
||||
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateFromLegacyPreferences(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
const migrationKey = `${STORAGE_PREFIX}::migrated`;
|
||||
|
||||
|
||||
try {
|
||||
// Skip if already migrated
|
||||
if (localStorage.getItem(migrationKey) === 'true') return;
|
||||
|
||||
|
||||
const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
|
||||
if (prefsRaw) {
|
||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||
|
||||
// Migrate based on legacy flags
|
||||
if (prefs.hasSeenIntroOnboarding === true) {
|
||||
markStepSeen('welcome');
|
||||
markStepSeen('desktop-install');
|
||||
markStepSeen('security-check');
|
||||
markStepSeen('admin-overview');
|
||||
|
||||
// If user had completed onboarding in old system, mark new system as complete
|
||||
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
|
||||
if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) {
|
||||
markStepSeen('tool-layout');
|
||||
}
|
||||
|
||||
if (prefs.hasCompletedOnboarding === true) {
|
||||
markStepSeen('tour');
|
||||
markStepSeen('analytics-choice');
|
||||
markStepSeen('server-license');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Mark migration complete
|
||||
localStorage.setItem(migrationKey, 'true');
|
||||
} catch {
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
DEFAULT_RUNTIME_STATE,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import {
|
||||
hasSeenStep,
|
||||
markStepSeen,
|
||||
isOnboardingCompleted,
|
||||
markOnboardingCompleted,
|
||||
migrateFromLegacyPreferences,
|
||||
} from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
@@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
||||
if (typeof window === 'undefined') {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
|
||||
const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
|
||||
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
||||
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
|
||||
? sessionTourType
|
||||
: 'whatsnew';
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
|
||||
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
tourRequested,
|
||||
@@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
|
||||
|
||||
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
if (state.tourRequested !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
|
||||
@@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
|
||||
function clearRuntimeStateSession(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
|
||||
sessionStorage.removeItem(SESSION_TOUR_TYPE);
|
||||
@@ -145,7 +148,7 @@ export function useOnboardingOrchestrator(
|
||||
const location = useLocation();
|
||||
const bypassOnboarding = useBypassOnboarding();
|
||||
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
getInitialRuntimeState(defaultState)
|
||||
);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
@@ -166,6 +169,7 @@ export function useOnboardingOrchestrator(
|
||||
...prev,
|
||||
analyticsEnabled: config?.enableAnalytics === true,
|
||||
analyticsNotConfigured: config?.enableAnalytics == null,
|
||||
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
|
||||
licenseNotice: {
|
||||
totalUsers: serverExperience.totalUsers,
|
||||
freeTierLimit: serverExperience.freeTierLimit,
|
||||
@@ -221,7 +225,7 @@ export function useOnboardingOrchestrator(
|
||||
const conditionContext = useMemo<OnboardingConditionContext>(() => ({
|
||||
...serverExperience,
|
||||
...runtimeState,
|
||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||
effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
|
||||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
|
||||
}), [serverExperience, runtimeState]);
|
||||
|
||||
@@ -235,53 +239,44 @@ export function useOnboardingOrchestrator(
|
||||
|
||||
// Wait for config AND admin status before calculating initial step
|
||||
const adminStatusResolved = !configLoading && (
|
||||
config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.isAdmin !== undefined
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
|
||||
if (configLoading || !adminStatusResolved) return;
|
||||
|
||||
let firstUnseenIndex = -1;
|
||||
for (let i = 0; i < activeFlow.length; i++) {
|
||||
// Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
|
||||
const isFirstLoginStep = activeFlow[i].id === 'first-login';
|
||||
const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
|
||||
|
||||
if (shouldTreatAsUnseen) {
|
||||
firstUnseenIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Force reset index when password change is required (overrides initialIndexSet)
|
||||
if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
|
||||
// If there are no steps to show, mark initialized/completed baseline
|
||||
if (activeFlow.length === 0) {
|
||||
setCurrentStepIndex(0);
|
||||
initialIndexSet.current = true;
|
||||
} else if (firstUnseenIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If onboarding has been completed, don't show it
|
||||
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) {
|
||||
setCurrentStepIndex(activeFlow.length);
|
||||
initialIndexSet.current = true;
|
||||
} else if (!initialIndexSet.current) {
|
||||
setCurrentStepIndex(firstUnseenIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start from the beginning
|
||||
if (!initialIndexSet.current) {
|
||||
setCurrentStepIndex(0);
|
||||
initialIndexSet.current = true;
|
||||
}
|
||||
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
|
||||
|
||||
const totalSteps = activeFlow.length;
|
||||
|
||||
const allStepsAlreadySeen = useMemo(() => {
|
||||
if (activeFlow.length === 0) return false;
|
||||
return activeFlow.every(step => hasSeenStep(step.id));
|
||||
}, [activeFlow]);
|
||||
|
||||
const isComplete = isInitialized && initialIndexSet.current &&
|
||||
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
|
||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
|
||||
? activeFlow[currentStepIndex]
|
||||
|
||||
const isComplete = isInitialized &&
|
||||
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
||||
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps)
|
||||
? activeFlow[currentStepIndex]
|
||||
: null;
|
||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||
const isLoading = configLoading || !adminStatusResolved || !isInitialized ||
|
||||
!initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -293,35 +288,33 @@ export function useOnboardingOrchestrator(
|
||||
}, [isComplete]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= totalSteps) {
|
||||
// Reached the end, mark onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
|
||||
}, []);
|
||||
|
||||
const skip = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
// Skip marks the entire onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
setCurrentStepIndex(totalSteps);
|
||||
}, [totalSteps]);
|
||||
|
||||
const complete = useCallback(() => {
|
||||
if (currentStep) markStepSeen(currentStep.id);
|
||||
setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
|
||||
}, [currentStep, totalSteps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStep || isLoading) {
|
||||
return;
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= totalSteps) {
|
||||
// Reached the end, mark onboarding as completed
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
// Special case: never auto-complete first-login step if requiresPasswordChange is true
|
||||
const isFirstLoginStep = currentStep.id === 'first-login';
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
|
||||
complete();
|
||||
}
|
||||
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
|
||||
|
||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Button } from '@mantine/core';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import i18n from '@app/i18n';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface AnalyticsChoiceSlideProps {
|
||||
analyticsError?: string | null;
|
||||
}
|
||||
|
||||
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'analytics-choice',
|
||||
title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'),
|
||||
body: (
|
||||
<div className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
i18nKey="analytics.paragraph1"
|
||||
defaults="Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans
|
||||
i18nKey="analytics.paragraph2"
|
||||
defaults="Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<div style={{ textAlign: 'right', marginTop: 0 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.stirlingpdf.com/analytics-telemetry/', '_blank')}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{i18n.t('analytics.learnMore', 'Learn more about our analytics')}
|
||||
</Button>
|
||||
</div>
|
||||
{analyticsError && (
|
||||
<div style={{ color: 'var(--mantine-color-red-6)', marginTop: 12 }}>
|
||||
{analyticsError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#0EA5E9', '#6366F1'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import i18n from '@app/i18n';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
export default function TourOverviewSlide(): SlideConfig {
|
||||
return {
|
||||
key: 'tour-overview',
|
||||
title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'),
|
||||
body: (
|
||||
<span className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
i18nKey="onboarding.tourOverview.body"
|
||||
defaults="Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#2563EB', '#7C3AED'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
|
||||
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
|
||||
const SESSION_KEY = 'onboarding::bypass-all';
|
||||
const PARAM_KEY = 'bypassOnboarding';
|
||||
@@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void {
|
||||
|
||||
/**
|
||||
* Detects the `bypassOnboarding` query parameter and stores it in session storage
|
||||
* so that onboarding remains disabled while the app is open. Also marks all steps
|
||||
* as seen to ensure any dependent UI elements remain hidden.
|
||||
* so that onboarding remains disabled while the app is open. Also marks onboarding
|
||||
* as completed to ensure any dependent UI elements remain hidden.
|
||||
*/
|
||||
export function useBypassOnboarding(): boolean {
|
||||
const location = useLocation();
|
||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
||||
const stepsMarkedRef = useRef(false);
|
||||
|
||||
const shouldBypassFromSearch = useMemo(() => {
|
||||
try {
|
||||
@@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean {
|
||||
setBypassOnboarding(nextBypass);
|
||||
if (nextBypass) {
|
||||
setStoredBypass(true);
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
}, [shouldBypassFromSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bypassOnboarding || stepsMarkedRef.current) return;
|
||||
stepsMarkedRef.current = true;
|
||||
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
|
||||
}, [bypassOnboarding]);
|
||||
|
||||
return bypassOnboarding;
|
||||
}
|
||||
|
||||
@@ -51,14 +51,14 @@ export function useTourRequest(): {
|
||||
clearTourRequest: () => void;
|
||||
} {
|
||||
const [tourRequested, setTourRequested] = useState(false);
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleTourRequest = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<StartTourPayload>;
|
||||
setRequestedTourType(detail?.tourType ?? 'tools');
|
||||
setRequestedTourType(detail?.tourType ?? 'whatsnew');
|
||||
setTourRequested(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,12 +8,8 @@ export enum TourStep {
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
@@ -28,10 +24,7 @@ interface UserStepActions {
|
||||
backToAllTools: () => void;
|
||||
selectCropTool: () => void;
|
||||
loadSampleFile: () => void;
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
@@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
@@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
@@ -141,14 +111,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
const start = Date.now();
|
||||
// Immediate hit
|
||||
if (document.querySelector(selector)) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (document.querySelector(selector) || Date.now() - start >= timeoutMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
const start = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
const isVisible = !!el && el.getClientRects().length > 0;
|
||||
if (isVisible || Date.now() - start >= timeoutMs) {
|
||||
// Nudge Reactour to recalc positions in case layout shifted
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, intervalMs);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
export enum WhatsNewTourStep {
|
||||
QUICK_ACCESS,
|
||||
LEFT_PANEL,
|
||||
FILE_UPLOAD,
|
||||
RIGHT_RAIL,
|
||||
TOP_BAR,
|
||||
PAGE_EDITOR_VIEW,
|
||||
ACTIVE_FILES_VIEW,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
interface WhatsNewStepActions {
|
||||
saveWorkbenchState: () => void;
|
||||
closeFilesModal: () => void;
|
||||
backToAllTools: () => void;
|
||||
openFilesModal: () => void;
|
||||
loadSampleFile: () => Promise<void> | void;
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
}
|
||||
|
||||
interface CreateWhatsNewStepsConfigArgs {
|
||||
t: TFunction;
|
||||
actions: WhatsNewStepActions;
|
||||
}
|
||||
|
||||
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
openFilesModal,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
[WhatsNewTourStep.QUICK_ACCESS]: {
|
||||
selector: '[data-tour="quick-access-bar"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.quickAccess',
|
||||
'Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.'
|
||||
),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.LEFT_PANEL]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.leftPanel',
|
||||
'The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.'
|
||||
),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[WhatsNewTourStep.FILE_UPLOAD]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.fileUpload',
|
||||
'Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.'
|
||||
),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
openFilesModal();
|
||||
await waitForElement('[data-tour="file-sources"]', 5000, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
await Promise.resolve(loadSampleFile());
|
||||
closeFilesModal();
|
||||
switchToViewer();
|
||||
// wait for file render and top controls to mount
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t(
|
||||
'onboarding.whatsNew.rightRail',
|
||||
'The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.'
|
||||
),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: async () => {
|
||||
await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
|
||||
selectFirstFile();
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.TOP_BAR]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.topBar',
|
||||
'The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.'
|
||||
),
|
||||
position: 'bottom',
|
||||
padding: 8,
|
||||
// Ensure the switcher has mounted before this step renders
|
||||
action: async () => {
|
||||
switchToViewer();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.pageEditorView',
|
||||
'Switch to the Page Editor to reorder, rotate, or delete pages.'
|
||||
),
|
||||
position: 'bottom',
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToPageEditor();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.activeFilesView',
|
||||
'Use Active Files to see everything you have open and pick what to work on.'
|
||||
),
|
||||
position: 'bottom',
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
switchToActiveFiles();
|
||||
await waitForElement('[data-tour="view-switcher"]', 7000, 100);
|
||||
await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
|
||||
},
|
||||
},
|
||||
[WhatsNewTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t(
|
||||
'onboarding.whatsNew.wrapUp',
|
||||
'That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.'
|
||||
),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
Button,
|
||||
Text,
|
||||
Title,
|
||||
Anchor,
|
||||
useMantineTheme,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface AdminAnalyticsChoiceModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { refetch } = useAppConfig();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const theme = useMantineTheme();
|
||||
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const isDark = computedColorScheme === 'dark';
|
||||
const privacyHighlightStyles = {
|
||||
color: isDark ? '#FFFFFF' : theme.colors.blue[7],
|
||||
padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
|
||||
borderRadius: theme.radius.md,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center' as const,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing.xs,
|
||||
letterSpacing: 0.3,
|
||||
};
|
||||
|
||||
const handleChoice = async (enableAnalytics: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enabled', enableAnalytics.toString());
|
||||
|
||||
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
|
||||
|
||||
// Refetch config to apply new settings without page reload
|
||||
await refetch();
|
||||
|
||||
// Close the modal after successful save
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = () => {
|
||||
handleChoice(true);
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
handleChoice(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {}} // Prevent closing
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
zIndex={Z_ANALYTICS_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')}
|
||||
</Text>
|
||||
<Text size="sm" style={privacyHighlightStyles}>
|
||||
• {t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')} •
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
|
||||
<Anchor
|
||||
href="https://docs.stirlingpdf.com/analytics-telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{t('analytics.learnMore', 'Learn more')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
>
|
||||
{t('analytics.enable', 'Enable analytics')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleDisable}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="gray"
|
||||
>
|
||||
{t('analytics.disable', 'Disable analytics')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
@@ -11,13 +10,11 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess
|
||||
interface AllToolsNavButtonProps {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||
activeButton,
|
||||
setActiveButton,
|
||||
tooltipPosition = 'right'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
@@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("quickAccess.allTools", "Tools")}
|
||||
position={tooltipPosition}
|
||||
arrow
|
||||
containerStyle={{ marginTop: "-1rem" }}
|
||||
maxWidth={200}
|
||||
>
|
||||
<div className="mt-4 mb-2">
|
||||
<QuickAccessButton
|
||||
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
||||
label={t("quickAccess.allTools", "Tools")}
|
||||
isActive={isActive}
|
||||
onClick={handleNavClick}
|
||||
href={navProps.href}
|
||||
ariaLabel={t("quickAccess.allTools", "Tools")}
|
||||
textClassName="all-tools-text"
|
||||
component="a"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="mt-4 mb-2">
|
||||
<QuickAccessButton
|
||||
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
||||
label={t("quickAccess.allTools", "Tools")}
|
||||
isActive={isActive}
|
||||
onClick={handleNavClick}
|
||||
href={navProps.href}
|
||||
ariaLabel={t("quickAccess.allTools", "Tools")}
|
||||
textClassName="all-tools-text"
|
||||
component="a"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
}), []);
|
||||
|
||||
// Get isAdmin and runningEE from app config
|
||||
const isAdmin = true // config?.isAdmin ?? false;
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const runningEE = config?.runningEE ?? false;
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
|
||||
@@ -95,6 +95,22 @@ export default function Footer({
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://discord.gg/Cn8pWhQRxZ"
|
||||
>
|
||||
{t('footer.discord', 'Discord')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
>
|
||||
{t('footer.issues', 'GitHub')}
|
||||
</a>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
|
||||
@@ -173,7 +173,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
|
||||
.map(([code, name]) => ({
|
||||
value: code,
|
||||
label: `${name} (${code})`,
|
||||
label: name,
|
||||
}));
|
||||
|
||||
// Hide the language selector if there's only one language option
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
|
||||
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
|
||||
import { ButtonConfig } from '@app/types/sidebar';
|
||||
import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
|
||||
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
@@ -18,6 +19,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import { requestStartTour } from '@app/constants/events';
|
||||
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
|
||||
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
|
||||
|
||||
import {
|
||||
isNavButtonActive,
|
||||
@@ -41,6 +43,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
tooltipOpen,
|
||||
manualCloseOnly,
|
||||
showCloseButton,
|
||||
toursMenuOpen,
|
||||
setToursMenuOpen,
|
||||
handleTooltipOpenChange,
|
||||
} = useToursTooltip();
|
||||
|
||||
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
|
||||
|
||||
@@ -166,8 +176,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const bottomButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'help',
|
||||
name: t("quickAccess.help", "Help"),
|
||||
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
|
||||
name: t("quickAccess.tours", "Tours"),
|
||||
icon: <LocalIcon icon="explore-rounded" width="1.25rem" height="1.25rem" />,
|
||||
isRound: true,
|
||||
size: 'md',
|
||||
type: 'action',
|
||||
@@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="quick-access"
|
||||
data-tour="quick-access-bar"
|
||||
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
>
|
||||
{/* Fixed header outside scrollable area */}
|
||||
@@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// Handle help button with menu or direct action
|
||||
if (buttonConfig.id === 'help') {
|
||||
const isAdmin = config?.isAdmin === true;
|
||||
const toursTooltipContent = isAdmin
|
||||
? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.')
|
||||
: t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.');
|
||||
const tourItems = [
|
||||
{
|
||||
key: 'whatsnew',
|
||||
icon: <LocalIcon icon="auto-awesome-rounded" width="1.25rem" height="1.25rem" />,
|
||||
title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"),
|
||||
description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"),
|
||||
onClick: () => requestStartTour('whatsnew'),
|
||||
},
|
||||
{
|
||||
key: 'tools',
|
||||
icon: <LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />,
|
||||
title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"),
|
||||
description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"),
|
||||
onClick: () => requestStartTour('tools'),
|
||||
},
|
||||
...(isAdmin ? [{
|
||||
key: 'admin',
|
||||
icon: <LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
title: t("quickAccess.helpMenu.adminTour", "Admin Tour"),
|
||||
description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"),
|
||||
onClick: () => requestStartTour('admin'),
|
||||
}] : []),
|
||||
];
|
||||
|
||||
// If not admin, just show button that starts tools tour directly
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div
|
||||
key={buttonConfig.id}
|
||||
data-tour="help-button"
|
||||
onClick={() => requestStartTour('tools')}
|
||||
const helpButtonNode = (
|
||||
<div data-tour="help-button">
|
||||
<Menu
|
||||
position={isRTL ? 'left' : 'right'}
|
||||
offset={10}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
opened={toursMenuOpen}
|
||||
onChange={setToursMenuOpen}
|
||||
>
|
||||
{renderNavButton(buttonConfig, index)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If admin, show menu with both options
|
||||
return (
|
||||
<div key={buttonConfig.id} data-tour="help-button">
|
||||
<Menu position={isRTL ? 'left' : 'right'} offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
|
||||
<Menu.Target>
|
||||
<div>{renderNavButton(buttonConfig, index)}</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => requestStartTour('tools')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
|
||||
{tourItems.map((item) => (
|
||||
<Menu.Item
|
||||
key={item.key}
|
||||
leftSection={item.icon}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
||||
{t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")}
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => requestStartTour('admin')}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t("quickAccess.helpMenu.adminTour", "Admin Tour")}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
|
||||
{t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")}
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
position="right"
|
||||
arrow
|
||||
offset={8}
|
||||
open={tooltipOpen}
|
||||
manualCloseOnly={manualCloseOnly}
|
||||
showCloseButton={showCloseButton}
|
||||
closeOnOutside={false}
|
||||
openOnFocus={false}
|
||||
content={toursTooltipContent}
|
||||
onOpenChange={handleTooltipOpenChange}
|
||||
>
|
||||
{helpButtonNode}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonNode = renderNavButton(buttonConfig, index);
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface TooltipProps {
|
||||
disabled?: boolean;
|
||||
/** If false, tooltip will not open on focus (hover only) */
|
||||
openOnFocus?: boolean;
|
||||
/** If true, tooltip stays open until explicitly closed (ignores hover/blur/esc/outside) */
|
||||
manualCloseOnly?: boolean;
|
||||
/** Show a close button even when not pinned */
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
@@ -55,6 +59,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
closeOnOutside = true,
|
||||
disabled = false,
|
||||
openOnFocus = true,
|
||||
manualCloseOnly = false,
|
||||
showCloseButton = false,
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
@@ -81,6 +87,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||
const allowAutoClose = !manualCloseOnly;
|
||||
|
||||
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
|
||||
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
|
||||
@@ -132,11 +139,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
|
||||
// Not pinned and configured to close on outside
|
||||
if (closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[isPinned, closeOnOutside, setOpen]
|
||||
[isPinned, closeOnOutside, setOpen, allowAutoClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -200,10 +207,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
},
|
||||
[clearTimers, isPinned, setOpen, children.props]
|
||||
[clearTimers, isPinned, setOpen, children.props, allowAutoClose]
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -257,15 +264,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
return;
|
||||
}
|
||||
clearTimers();
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
},
|
||||
[isPinned, setOpen, children.props, clearTimers]
|
||||
[isPinned, setOpen, children.props, allowAutoClose, clearTimers]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (manualCloseOnly) return;
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}, [setOpen]);
|
||||
}, [setOpen, manualCloseOnly]);
|
||||
|
||||
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
|
||||
const handleTooltipPointerEnter = useCallback(() => {
|
||||
@@ -276,9 +284,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
if (allowAutoClose && !isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
[isPinned, setOpen, allowAutoClose]
|
||||
);
|
||||
|
||||
// Enhance child with handlers and ref
|
||||
@@ -301,6 +309,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
});
|
||||
|
||||
const shouldShowTooltip = open;
|
||||
const shouldShowCloseButton = showCloseButton || isPinned;
|
||||
|
||||
const tooltipElement = shouldShowTooltip ? (
|
||||
<div
|
||||
@@ -325,7 +334,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
|
||||
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
|
||||
>
|
||||
{isPinned && (
|
||||
{shouldShowCloseButton && (
|
||||
<button
|
||||
className={styles['tooltip-pin-button']}
|
||||
onClick={(e) => {
|
||||
@@ -363,7 +372,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
<span className={styles['tooltip-title']}>{header.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<TooltipContent content={content} tips={tips} />
|
||||
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export const VALID_NAV_KEYS = [
|
||||
'preferences',
|
||||
'notifications',
|
||||
'connections',
|
||||
'account',
|
||||
'general',
|
||||
'people',
|
||||
'teams',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events';
|
||||
import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage';
|
||||
|
||||
export interface ToursTooltipState {
|
||||
tooltipOpen: boolean | undefined;
|
||||
manualCloseOnly: boolean;
|
||||
showCloseButton: boolean;
|
||||
toursMenuOpen: boolean;
|
||||
setToursMenuOpen: (open: boolean) => void;
|
||||
handleTooltipOpenChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates all the logic for the tours tooltip:
|
||||
* - Shows automatically after onboarding/tour completes (once per user)
|
||||
* - Hides while the tours menu is open
|
||||
* - After dismissal, reverts to hover-only tooltip
|
||||
*/
|
||||
export function useToursTooltip(): ToursTooltipState {
|
||||
const [showToursTooltip, setShowToursTooltip] = useState(false);
|
||||
const [toursMenuOpen, setToursMenuOpen] = useState(false);
|
||||
const tourWasOpenRef = useRef(false);
|
||||
|
||||
// Auto-show when a tour ends (fires once per user)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleTourStateChange = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<TourStatePayload>;
|
||||
const wasOpen = tourWasOpenRef.current;
|
||||
tourWasOpenRef.current = detail.isOpen;
|
||||
|
||||
if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange);
|
||||
return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange);
|
||||
}, []);
|
||||
|
||||
// Show once after onboarding is complete
|
||||
useEffect(() => {
|
||||
if (isOnboardingCompleted() && !hasShownToursTooltip()) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismissToursTooltip = useCallback(() => {
|
||||
markToursTooltipShown();
|
||||
setShowToursTooltip(false);
|
||||
}, []);
|
||||
|
||||
const hasBeenDismissed = hasShownToursTooltip();
|
||||
|
||||
const handleTooltipOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (!next) {
|
||||
if (!hasBeenDismissed) {
|
||||
handleDismissToursTooltip();
|
||||
}
|
||||
} else if (!hasBeenDismissed && !toursMenuOpen) {
|
||||
setShowToursTooltip(true);
|
||||
}
|
||||
},
|
||||
[hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip]
|
||||
);
|
||||
|
||||
const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip;
|
||||
|
||||
return {
|
||||
tooltipOpen,
|
||||
manualCloseOnly: !hasBeenDismissed,
|
||||
showCloseButton: !hasBeenDismissed,
|
||||
toursMenuOpen,
|
||||
setToursMenuOpen,
|
||||
handleTooltipOpenChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { useFileState, useFileContext } from '@app/contexts/FileContext';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createProcessedFile } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
@@ -23,32 +17,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
const { t } = useTranslation();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
const { signatureApiRef, isPlacementMode } = useSignature();
|
||||
|
||||
// File state for save functionality
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Check if we're in sign mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignMode = selectedTool === 'sign';
|
||||
|
||||
// Turn off annotation mode when switching away from viewer
|
||||
useEffect(() => {
|
||||
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
}
|
||||
}, [currentView, viewerContext]);
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
@@ -65,7 +41,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
|
||||
disabled={disabled || currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
@@ -74,164 +50,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Mode Toggle with Drawing Controls */}
|
||||
{viewerContext?.isAnnotationMode ? (
|
||||
// When active: Show color picker on hover
|
||||
<div
|
||||
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
|
||||
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<Popover
|
||||
opened={isHoverColorPickerOpen}
|
||||
onClose={() => setIsHoverColorPickerOpen(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '8rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
size={32}
|
||||
onClick={() => {
|
||||
setIsHoverColorPickerOpen(false); // Close hover picker
|
||||
setIsColorPickerOpen(true); // Open main color picker modal
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
// When inactive: Show "Draw" tooltip
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
// Activate ink drawing tool when entering annotation mode
|
||||
if (signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Save PDF with Annotations */}
|
||||
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={async () => {
|
||||
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
|
||||
try {
|
||||
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
|
||||
if (pdfArrayBuffer) {
|
||||
// Create new File object with flattened annotations
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
// Get the original file name or use a default
|
||||
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
|
||||
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
|
||||
|
||||
// Replace the current file in context with the saved version (exact same logic as Sign tool)
|
||||
if (activeFiles.length > 0) {
|
||||
// Generate thumbnail and metadata for the saved file
|
||||
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
|
||||
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
|
||||
|
||||
// Get current file info
|
||||
const currentFileIds = state.files.ids;
|
||||
if (currentFileIds.length > 0) {
|
||||
const currentFileId = currentFileIds[0];
|
||||
const currentRecord = selectors.getStirlingFileStub(currentFileId);
|
||||
|
||||
if (!currentRecord) {
|
||||
console.error('No file record found for:', currentFileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create output stub and file (exact same as Sign tool)
|
||||
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
|
||||
|
||||
// Replace the original file with the saved version
|
||||
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving PDF:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={(color) => {
|
||||
setSelectedColor(color);
|
||||
// Update drawing tool color if annotation mode is active
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.updateDrawSettings(color, 2);
|
||||
} catch (error) {
|
||||
console.log('Unable to update drawing settings:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Choose Drawing Color"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
background: var(--bg-raised);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.0625rem solid var(--primary-color, #3b82f6);
|
||||
border: 0.0625rem solid var(--border-default);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
z-index: 1;
|
||||
@@ -60,6 +60,13 @@
|
||||
border-color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.tooltip-pin-button:focus,
|
||||
.tooltip-pin-button:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-default) !important;
|
||||
background-color: var(--bg-raised) !important;
|
||||
}
|
||||
|
||||
/* Tooltip Header */
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
@@ -91,7 +98,7 @@
|
||||
|
||||
/* Tooltip Body */
|
||||
.tooltip-body {
|
||||
padding: 1rem !important;
|
||||
padding: 1rem;
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.6 !important;
|
||||
|
||||
@@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips';
|
||||
interface TooltipContentProps {
|
||||
content?: React.ReactNode;
|
||||
tips?: TooltipTip[];
|
||||
extraRightPadding?: number;
|
||||
}
|
||||
|
||||
export const TooltipContent: React.FC<TooltipContentProps> = ({
|
||||
content,
|
||||
tips,
|
||||
extraRightPadding = 0,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`${styles['tooltip-body']}`}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
padding: '16px',
|
||||
padding: `16px ${16 + extraRightPadding}px 16px 16px`,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6'
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
|
||||
import ButtonSelector from "@app/components/shared/ButtonSelector";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
interface CompressSettingsProps {
|
||||
parameters: CompressParameters;
|
||||
@@ -13,6 +14,20 @@ interface CompressSettingsProps {
|
||||
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSliding, setIsSliding] = useState(false);
|
||||
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkImageMagick = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<boolean>('/api/v1/config/group-enabled?group=ImageMagick');
|
||||
setImageMagickAvailable(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to check ImageMagick availability:', error);
|
||||
setImageMagickAvailable(true); // Optimistic fallback
|
||||
}
|
||||
};
|
||||
checkImageMagick();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -129,6 +144,62 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
||||
disabled={disabled}
|
||||
label={t("compress.grayscale.label", "Apply Grayscale for compression")}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={parameters.lineArt}
|
||||
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
|
||||
description={
|
||||
imageMagickAvailable === false
|
||||
? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
|
||||
: t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
|
||||
}
|
||||
/>
|
||||
{parameters.lineArt && (
|
||||
<Stack gap="xs" style={{ opacity: (disabled || imageMagickAvailable === false) ? 0.6 : 1 }}>
|
||||
<Text size="sm" fw={600}>{t('compress.lineArt.detailLevel', 'Detail level')}</Text>
|
||||
<Slider
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
value={(() => {
|
||||
// Map threshold to slider position
|
||||
const thresholdMap = [20, 35, 50, 65, 80];
|
||||
const closest = thresholdMap.reduce((prev, curr, idx) =>
|
||||
Math.abs(curr - parameters.lineArtThreshold) < Math.abs(thresholdMap[prev] - parameters.lineArtThreshold)
|
||||
? idx : prev, 0);
|
||||
return closest + 1;
|
||||
})()}
|
||||
onChange={(value) => {
|
||||
// Map slider position to threshold: 1=20%, 2=35%, 3=50%, 4=65%, 5=80%
|
||||
const thresholdMap = [20, 35, 50, 65, 80];
|
||||
onParameterChange('lineArtThreshold', thresholdMap[value - 1]);
|
||||
}}
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
label={null}
|
||||
marks={[
|
||||
{ value: 1 },
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
{ value: 4 },
|
||||
{ value: 5 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600}>{t('compress.lineArt.edgeEmphasis', 'Edge emphasis')}</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={disabled || imageMagickAvailable === false}
|
||||
data={[
|
||||
{ value: '1', label: t('compress.lineArt.edgeLow', 'Gentle') },
|
||||
{ value: '2', label: t('compress.lineArt.edgeMedium', 'Balanced') },
|
||||
{ value: '3', label: t('compress.lineArt.edgeHigh', 'Strong') },
|
||||
]}
|
||||
value={parameters.lineArtEdgeLevel.toString()}
|
||||
onChange={(value) => onParameterChange('lineArtEdgeLevel', parseInt(value) as 1 | 2 | 3)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -853,6 +853,12 @@ const SignSettings = ({
|
||||
textColor={parameters.textColor || '#000000'}
|
||||
onTextColorChange={(color) => onParameterChange('textColor', color)}
|
||||
disabled={disabled}
|
||||
label={translate('text.name', 'Text')}
|
||||
placeholder={translate('text.placeholder', 'Enter text')}
|
||||
fontLabel={translate('text.fontLabel', 'Font')}
|
||||
fontSizeLabel={translate('text.fontSizeLabel', 'Font size')}
|
||||
fontSizePlaceholder={translate('text.fontSizePlaceholder', 'Type or select font size (8-200)')}
|
||||
colorLabel={translate('text.colorLabel', 'Text colour')}
|
||||
onAnyChange={() => {
|
||||
setPlacementManuallyPaused(false);
|
||||
lastAppliedPlacementKey.current = null;
|
||||
|
||||
@@ -24,6 +24,13 @@ export const useCompressTips = (): TooltipContent => {
|
||||
{
|
||||
title: t("compress.tooltip.grayscale.title", "Grayscale"),
|
||||
description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.")
|
||||
},
|
||||
{
|
||||
title: t("compress.tooltip.lineArt.title", "Line Art"),
|
||||
description: t(
|
||||
"compress.tooltip.lineArt.text",
|
||||
"Convert pages to high-contrast black and white using ImageMagick. Use line thickness to control the threshold percentage and detection strength to choose how aggressively edges are outlined."
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -44,8 +44,10 @@ export function CustomSearchLayer({
|
||||
}
|
||||
|
||||
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
|
||||
if (!state) return;
|
||||
|
||||
// Auto-scroll to active search result
|
||||
if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
|
||||
if (state.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
|
||||
const activeResult = state.results[state.activeResultIndex];
|
||||
if (activeResult) {
|
||||
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number
|
||||
|
||||
@@ -43,12 +43,15 @@ const EmbedPdfViewerContent = ({
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
isBookmarkSidebarVisible,
|
||||
isSearchInterfaceVisible,
|
||||
searchInterfaceActions,
|
||||
zoomActions,
|
||||
panActions: _panActions,
|
||||
rotationActions: _rotationActions,
|
||||
getScrollState,
|
||||
getRotationState,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
isAnnotationsVisible,
|
||||
exportActions,
|
||||
} = useViewer();
|
||||
@@ -80,15 +83,18 @@ const EmbedPdfViewerContent = ({
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
|
||||
// Check if we're in signature mode OR viewer annotation mode
|
||||
// Check if we're in an annotation tool
|
||||
const { selectedTool } = useNavigationState();
|
||||
// Tools that use the stamp/signature placement system with hover preview
|
||||
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
|
||||
// Sync isAnnotationMode in ViewerContext with current tool
|
||||
useEffect(() => {
|
||||
setAnnotationMode(isInAnnotationTool);
|
||||
}, [isInAnnotationTool, setAnnotationMode]);
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
isInAnnotationTool && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
@@ -184,7 +190,7 @@ const EmbedPdfViewerContent = ({
|
||||
onZoomOut: zoomActions.zoomOut,
|
||||
});
|
||||
|
||||
// Handle keyboard zoom shortcuts
|
||||
// Handle keyboard shortcuts (zoom and search)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isViewerHovered) return;
|
||||
@@ -199,6 +205,16 @@ const EmbedPdfViewerContent = ({
|
||||
// Ctrl+- for zoom out
|
||||
event.preventDefault();
|
||||
zoomActions.zoomOut();
|
||||
} else if (event.key === 'f' || event.key === 'F') {
|
||||
// Ctrl+F for search
|
||||
event.preventDefault();
|
||||
if (isSearchInterfaceVisible) {
|
||||
// If already open, trigger refocus event
|
||||
window.dispatchEvent(new CustomEvent('refocus-search-input'));
|
||||
} else {
|
||||
// Open search interface
|
||||
searchInterfaceActions.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -207,7 +223,7 @@ const EmbedPdfViewerContent = ({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isViewerHovered]);
|
||||
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
@@ -321,7 +337,8 @@ const EmbedPdfViewerContent = ({
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
url={effectiveFile.url}
|
||||
enableAnnotations={shouldEnableAnnotations}
|
||||
enableAnnotations={isAnnotationMode}
|
||||
showBakedAnnotations={isAnnotationsVisible}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
|
||||
@@ -46,17 +46,19 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||
import { isPdfFile } from '@app/utils/fileUtils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LinkLayer } from '@app/components/viewer/LinkLayer';
|
||||
import { absoluteWithBasePath } from '@app/constants/app';
|
||||
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
enableAnnotations?: boolean;
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
@@ -99,7 +101,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
}),
|
||||
createPluginRegistration(RenderPluginPackage, {
|
||||
withForms: true,
|
||||
withAnnotations: true,
|
||||
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
|
||||
}),
|
||||
|
||||
// Register interaction manager (required for zoom and selection features)
|
||||
@@ -165,10 +167,12 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
// Register print plugin for printing PDFs
|
||||
createPluginRegistration(PrintPluginPackage),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
}, [pdfUrl, enableAnnotations, showBakedAnnotations]);
|
||||
|
||||
// Initialize the engine with the React hook
|
||||
const { engine, isLoading, error } = usePdfiumEngine();
|
||||
// Initialize the engine with the React hook - use local WASM for offline support
|
||||
const { engine, isLoading, error } = usePdfiumEngine({
|
||||
wasmUrl: absoluteWithBasePath('/pdfium/pdfium.wasm'),
|
||||
});
|
||||
|
||||
|
||||
// Early return if no file or URL provided
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Button, Paper, Group, NumberInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import FirstPageIcon from '@mui/icons-material/FirstPage';
|
||||
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||
@@ -209,21 +210,27 @@ export function PdfViewerToolbar({
|
||||
</Button>
|
||||
|
||||
{/* Dual Page Toggle */}
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={
|
||||
<Tooltip
|
||||
content={
|
||||
isDualPageActive
|
||||
? t("viewer.singlePageView", "Single Page View")
|
||||
: t("viewer.dualPageView", "Dual Page View")
|
||||
}
|
||||
position="top"
|
||||
arrow
|
||||
>
|
||||
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
>
|
||||
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||
|
||||
@@ -28,11 +28,13 @@ export function SearchAPIBridge() {
|
||||
if (!search) return;
|
||||
|
||||
const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
|
||||
if (!state) return;
|
||||
|
||||
const newState = {
|
||||
results: state?.results || null,
|
||||
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index
|
||||
results: state.results || null,
|
||||
activeIndex: (state.activeResultIndex || 0) + 1 // Convert to 1-based index
|
||||
};
|
||||
|
||||
|
||||
setLocalState(prevState => {
|
||||
// Only update if state actually changed
|
||||
if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) {
|
||||
@@ -52,16 +54,42 @@ export function SearchAPIBridge() {
|
||||
state: localState,
|
||||
api: {
|
||||
search: async (query: string) => {
|
||||
search.startSearch();
|
||||
return search.searchAllPages(query);
|
||||
if (search?.startSearch && search?.searchAllPages) {
|
||||
search.startSearch();
|
||||
return search.searchAllPages(query);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
search.stopSearch();
|
||||
try {
|
||||
if (search?.stopSearch) {
|
||||
search.stopSearch();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error stopping search:', error);
|
||||
}
|
||||
setLocalState({ results: null, activeIndex: 0 });
|
||||
},
|
||||
next: () => search.nextResult(),
|
||||
previous: () => search.previousResult(),
|
||||
goToResult: (index: number) => search.goToResult(index),
|
||||
next: () => {
|
||||
try {
|
||||
search?.nextResult?.();
|
||||
} catch (error) {
|
||||
console.warn('Error navigating to next result:', error);
|
||||
}
|
||||
},
|
||||
previous: () => {
|
||||
try {
|
||||
search?.previousResult?.();
|
||||
} catch (error) {
|
||||
console.warn('Error navigating to previous result:', error);
|
||||
}
|
||||
},
|
||||
goToResult: (index: number) => {
|
||||
try {
|
||||
search?.goToResult?.(index);
|
||||
} catch (error) {
|
||||
console.warn('Error going to result:', error);
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
@@ -12,7 +12,9 @@ interface SearchInterfaceProps {
|
||||
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const searchState = viewerContext?.getSearchState();
|
||||
const searchResults = searchState?.results;
|
||||
const searchActiveIndex = searchState?.activeIndex;
|
||||
@@ -26,6 +28,61 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
} | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Auto-focus search input when visible
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Listen for refocus event (when Ctrl+F pressed while already open)
|
||||
useEffect(() => {
|
||||
const handleRefocus = () => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
|
||||
window.addEventListener('refocus-search-input', handleRefocus);
|
||||
return () => {
|
||||
window.removeEventListener('refocus-search-input', handleRefocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-search as user types (debounced)
|
||||
useEffect(() => {
|
||||
// Clear existing timeout
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// If query is empty, clear search immediately
|
||||
if (!searchQuery.trim()) {
|
||||
searchActions?.clear();
|
||||
setResultInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce search by 300ms
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
if (searchQuery.trim() && searchActions) {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
await searchActions.search(searchQuery.trim());
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, searchActions]);
|
||||
|
||||
// Monitor search state changes
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
@@ -59,30 +116,21 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, searchResults, searchActiveIndex, searchQuery]);
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
// If query is empty, clear the search
|
||||
handleClearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (query.trim() && searchActions) {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
await searchActions.search(query.trim());
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSearch(searchQuery);
|
||||
// Navigate to next result on Enter
|
||||
event.preventDefault();
|
||||
handleNext();
|
||||
} else if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
// Navigate to next result
|
||||
event.preventDefault();
|
||||
handleNext();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
// Navigate to previous result
|
||||
event.preventDefault();
|
||||
handlePrevious();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,17 +151,17 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
// No longer need to sync with external API on mount - removed
|
||||
|
||||
const handleJumpToResult = (index: number) => {
|
||||
// Use context actions instead of window API - functionality simplified for now
|
||||
if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
// Note: goToResult functionality would need to be implemented in SearchAPIBridge
|
||||
console.log('Jump to result:', index);
|
||||
// Convert to 0-based index for the API
|
||||
searchActions?.goToResult?.(index - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToSubmit = () => {
|
||||
const index = parseInt(jumpToValue);
|
||||
if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
const index = parseInt(jumpToValue, 10);
|
||||
if (!isNaN(index) && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
|
||||
handleJumpToResult(index);
|
||||
setJumpToValue(''); // Clear the input after jumping
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,7 +171,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const _handleClose = () => {
|
||||
const handleInputBlur = () => {
|
||||
// Close popover on blur if no text is entered
|
||||
if (!searchQuery.trim()) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseClick = () => {
|
||||
handleClearSearch();
|
||||
onClose();
|
||||
};
|
||||
@@ -135,100 +190,99 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
||||
padding: '0px'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
{/* Header with close button */}
|
||||
<Group mb="md" justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('search.title', 'Search PDF')}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleCloseClick}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Search input */}
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
placeholder={t('search.placeholder', 'Enter search term...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setSearchQuery(newValue);
|
||||
// If user clears the input, clear the search highlights
|
||||
if (!newValue.trim()) {
|
||||
handleClearSearch();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
style={{ flex: 1 }}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => handleSearch(searchQuery)}
|
||||
disabled={!searchQuery.trim() || isSearching}
|
||||
loading={isSearching}
|
||||
>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
searchQuery.trim() && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Results info and navigation */}
|
||||
{resultInfo && (
|
||||
<Group justify="space-between" align="center">
|
||||
{resultInfo.totalResults === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('search.noResults', 'No results found')}
|
||||
</Text>
|
||||
) : (
|
||||
<Group gap="xs" align="center">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={jumpToValue}
|
||||
onChange={(e) => setJumpToValue(e.currentTarget.value)}
|
||||
onKeyDown={handleJumpToKeyDown}
|
||||
onBlur={handleJumpToSubmit}
|
||||
placeholder={resultInfo.currentIndex.toString()}
|
||||
style={{ width: '3rem' }}
|
||||
type="number"
|
||||
min="1"
|
||||
max={resultInfo.totalResults}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
of {resultInfo.totalResults}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{resultInfo.totalResults > 0 && (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={resultInfo.currentIndex <= 1}
|
||||
aria-label="Previous result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={resultInfo.currentIndex >= resultInfo.totalResults}
|
||||
aria-label="Next result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
{/* Results info and navigation - always show */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs" align="center">
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={jumpToValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.currentTarget.value;
|
||||
setJumpToValue(newValue);
|
||||
|
||||
// Jump immediately as user types
|
||||
const index = parseInt(newValue, 10);
|
||||
if (resultInfo && !isNaN(index) && index >= 1 && index <= resultInfo.totalResults) {
|
||||
handleJumpToResult(index);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleJumpToKeyDown}
|
||||
onBlur={() => setJumpToValue('')} // Clear on blur instead of submit
|
||||
placeholder={(resultInfo?.currentIndex || 0).toString()}
|
||||
style={{ width: '3rem' }}
|
||||
type="number"
|
||||
min="1"
|
||||
max={resultInfo?.totalResults || 0}
|
||||
disabled={!resultInfo || resultInfo.totalResults === 0}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
of {resultInfo?.totalResults || 0}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={!resultInfo || resultInfo.currentIndex <= 1}
|
||||
aria-label="Previous result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults}
|
||||
aria-label="Next result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
|
||||
@@ -36,7 +36,14 @@ export function useViewerRightRailButtons() {
|
||||
order: 10,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
|
||||
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}>
|
||||
<Popover
|
||||
position={tooltipPosition}
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
opened={viewer.isSearchInterfaceVisible}
|
||||
onClose={viewer.searchInterfaceActions.close}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
@@ -45,6 +52,7 @@ export function useViewerRightRailButtons() {
|
||||
className="right-rail-icon"
|
||||
disabled={disabled}
|
||||
aria-label={searchLabel}
|
||||
onClick={viewer.searchInterfaceActions.toggle}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
@@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface visible={true} onClose={() => {}} />
|
||||
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface UpgradeBannerAlertPayload {
|
||||
freeTierLimit?: number;
|
||||
}
|
||||
|
||||
export type TourType = 'admin' | 'tools';
|
||||
export type TourType = 'admin' | 'tools' | 'whatsnew';
|
||||
|
||||
export interface StartTourPayload {
|
||||
tourType: TourType;
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface AppConfig {
|
||||
enableAnalytics?: boolean | null;
|
||||
enablePosthog?: boolean | null;
|
||||
enableScarf?: boolean | null;
|
||||
enableDesktopInstallSlide?: boolean;
|
||||
premiumEnabled?: boolean;
|
||||
premiumKey?: string;
|
||||
termsAndConditions?: string;
|
||||
|
||||
@@ -111,6 +111,8 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
|
||||
|
||||
const loadSampleFile = useCallback(async () => {
|
||||
try {
|
||||
// Hide the modal immediately so the tour targets are visible while we load
|
||||
closeFilesModal();
|
||||
const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
|
||||
|
||||
@@ -80,6 +80,14 @@ interface ViewerContextType {
|
||||
isBookmarkSidebarVisible: boolean;
|
||||
toggleBookmarkSidebar: () => void;
|
||||
|
||||
// Search interface visibility
|
||||
isSearchInterfaceVisible: boolean;
|
||||
searchInterfaceActions: {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
// Annotation visibility toggle
|
||||
isAnnotationsVisible: boolean;
|
||||
toggleAnnotationsVisibility: () => void;
|
||||
@@ -87,7 +95,6 @@ interface ViewerContextType {
|
||||
// Annotation/drawing mode for viewer
|
||||
isAnnotationMode: boolean;
|
||||
setAnnotationMode: (enabled: boolean) => void;
|
||||
toggleAnnotationMode: () => void;
|
||||
|
||||
// Active file index for multi-file viewing
|
||||
activeFileIndex: number;
|
||||
@@ -145,6 +152,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
|
||||
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
|
||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||
@@ -207,6 +215,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
setIsBookmarkSidebarVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const searchInterfaceActions = {
|
||||
open: () => setSearchInterfaceVisible(true),
|
||||
close: () => setSearchInterfaceVisible(false),
|
||||
toggle: () => setSearchInterfaceVisible(prev => !prev),
|
||||
};
|
||||
|
||||
const toggleAnnotationsVisibility = () => {
|
||||
setIsAnnotationsVisible(prev => !prev);
|
||||
};
|
||||
@@ -215,10 +229,6 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
setIsAnnotationModeState(enabled);
|
||||
};
|
||||
|
||||
const toggleAnnotationMode = () => {
|
||||
setIsAnnotationModeState(prev => !prev);
|
||||
};
|
||||
|
||||
// State getters - read from bridge refs
|
||||
const getScrollState = (): ScrollState => {
|
||||
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
|
||||
@@ -294,12 +304,15 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
isBookmarkSidebarVisible,
|
||||
toggleBookmarkSidebar,
|
||||
|
||||
// Search interface
|
||||
isSearchInterfaceVisible,
|
||||
searchInterfaceActions,
|
||||
|
||||
// Annotation controls
|
||||
isAnnotationsVisible,
|
||||
toggleAnnotationsVisibility,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
toggleAnnotationMode,
|
||||
|
||||
// Active file index
|
||||
activeFileIndex,
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface SearchActions {
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
clear: () => void;
|
||||
goToResult: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface ExportActions {
|
||||
@@ -287,6 +288,12 @@ export function createViewerActions({
|
||||
api.clear();
|
||||
}
|
||||
},
|
||||
goToResult: (index: number) => {
|
||||
const api = registry.current.search?.api;
|
||||
if (api?.goToResult) {
|
||||
api.goToResult(index);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const exportActions: ExportActions = {
|
||||
|
||||
@@ -19,6 +19,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File
|
||||
}
|
||||
|
||||
formData.append("grayscale", parameters.grayscale.toString());
|
||||
formData.append("lineArt", parameters.lineArt.toString());
|
||||
if (parameters.lineArt) {
|
||||
formData.append("lineArtThreshold", parameters.lineArtThreshold.toString());
|
||||
formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString());
|
||||
}
|
||||
return formData;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/u
|
||||
export interface CompressParameters extends BaseParameters {
|
||||
compressionLevel: number;
|
||||
grayscale: boolean;
|
||||
lineArt: boolean;
|
||||
lineArtThreshold: number;
|
||||
lineArtEdgeLevel: 1 | 2 | 3;
|
||||
expectedSize: string;
|
||||
compressionMethod: 'quality' | 'filesize';
|
||||
fileSizeValue: string;
|
||||
@@ -13,6 +16,9 @@ export interface CompressParameters extends BaseParameters {
|
||||
export const defaultParameters: CompressParameters = {
|
||||
compressionLevel: 5,
|
||||
grayscale: false,
|
||||
lineArt: false,
|
||||
lineArtThreshold: 50,
|
||||
lineArtEdgeLevel: 3,
|
||||
expectedSize: '',
|
||||
compressionMethod: 'quality',
|
||||
fileSizeValue: '',
|
||||
|
||||
@@ -64,7 +64,10 @@ export function useServerExperience(): ServerExperienceValue {
|
||||
|
||||
const loginEnabled = config?.enableLogin !== false;
|
||||
const configIsAdmin = Boolean(config?.isAdmin);
|
||||
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
|
||||
// For no-login servers, treat everyone as a regular user (no effective admin)
|
||||
// Commented out the previous self-reported admin path to avoid elevating users.
|
||||
// const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
|
||||
const effectiveIsAdmin = loginEnabled ? configIsAdmin : false;
|
||||
const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
|
||||
|
||||
const setSelfReportedAdmin = useCallback((value: boolean) => {
|
||||
|
||||
@@ -56,4 +56,14 @@ export const accountService = {
|
||||
formData.append('newPassword', newPassword);
|
||||
await apiClient.post('/api/v1/user/change-password-on-login', formData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Change username
|
||||
*/
|
||||
async changeUsername(newUsername: string, currentPassword: string): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('currentPasswordChangeUsername', currentPassword);
|
||||
formData.append('newUsername', newUsername);
|
||||
await apiClient.post('/api/v1/user/change-username', formData);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,7 +49,9 @@ const auditService = {
|
||||
* Get audit system status
|
||||
*/
|
||||
async getSystemStatus(): Promise<AuditSystemStatus> {
|
||||
const response = await apiClient.get<any>('/api/v1/proprietary/ui-data/audit-dashboard');
|
||||
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
const data = response.data;
|
||||
|
||||
// Map V1 response to expected format
|
||||
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.0.0',
|
||||
appVersion: '2.1.3',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -238,6 +238,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const originalImagesRef = useRef<PdfJsonImageElement[][]>([]);
|
||||
const originalGroupsRef = useRef<TextGroup[][]>([]);
|
||||
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
|
||||
const lastLoadedFileRef = useRef<File | null>(null);
|
||||
const autoLoadKeyRef = useRef<string | null>(null);
|
||||
const sourceFileIdRef = useRef<string | null>(null);
|
||||
const loadRequestIdRef = useRef(0);
|
||||
@@ -251,6 +252,10 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const pagePreviewsRef = useRef<Map<number, string>>(pagePreviews);
|
||||
const previewScaleRef = useRef<Map<number, number>>(new Map());
|
||||
const cachedJobIdRef = useRef<string | null>(null);
|
||||
const previousCachedJobIdRef = useRef<string | null>(null);
|
||||
const cacheRecoveryInProgressRef = useRef(false);
|
||||
const cacheRecoveryAttemptsRef = useRef(0);
|
||||
const recoverCacheAndReloadRef = useRef<() => Promise<boolean>>(async () => false);
|
||||
|
||||
// Keep ref in sync with state for access in async callbacks
|
||||
useEffect(() => {
|
||||
@@ -279,6 +284,13 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isCacheUnavailableError = useCallback((error: any): boolean => {
|
||||
const status = error?.response?.status;
|
||||
// Treat any 410 as cache unavailable, since responseType: 'blob' makes
|
||||
// it impossible to reliably check the JSON body
|
||||
return status === 410;
|
||||
}, []);
|
||||
|
||||
const dirtyPages = useMemo(
|
||||
() => getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current),
|
||||
[groupsByPage, imagesByPage],
|
||||
@@ -316,6 +328,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
loadedImagePagesRef.current = new Set();
|
||||
loadingImagePagesRef.current = new Set();
|
||||
setSelectedPage(0);
|
||||
setIsLazyMode(false);
|
||||
setCachedJobId(null);
|
||||
cachedJobIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
const cloned = deepCloneDocument(document);
|
||||
@@ -365,11 +380,14 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const previousJobId = cachedJobIdRef.current;
|
||||
// Clear old cached job when job ID changes
|
||||
const previousJobId = previousCachedJobIdRef.current;
|
||||
if (previousJobId && previousJobId !== cachedJobId) {
|
||||
console.log(`[PdfTextEditor] Clearing old cache for jobId: ${previousJobId}, new jobId: ${cachedJobId}`);
|
||||
clearCachedJob(previousJobId);
|
||||
}
|
||||
cachedJobIdRef.current = cachedJobId;
|
||||
// Update the previous jobId ref for next time
|
||||
previousCachedJobIdRef.current = cachedJobId;
|
||||
}, [cachedJobId, clearCachedJob]);
|
||||
|
||||
const initializePdfPreview = useCallback(
|
||||
@@ -489,6 +507,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[loadImagesForPage] Failed to load images for page ${pageNumber}:`, error);
|
||||
if (isCacheUnavailableError(error)) {
|
||||
console.log('[loadImagesForPage] Cache expired, triggering automatic recovery...');
|
||||
// Automatically recover by reloading the file
|
||||
void recoverCacheAndReloadRef.current();
|
||||
}
|
||||
} finally {
|
||||
loadingImagePagesRef.current.delete(pageIndex);
|
||||
setLoadingImagePages((prev) => {
|
||||
@@ -498,7 +521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[isLazyMode, cachedJobId],
|
||||
[isLazyMode, cachedJobId, isCacheUnavailableError],
|
||||
);
|
||||
|
||||
const handleLoadFile = useCallback(
|
||||
@@ -507,6 +530,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
lastLoadedFileRef.current = file;
|
||||
const requestId = loadRequestIdRef.current + 1;
|
||||
loadRequestIdRef.current = requestId;
|
||||
|
||||
@@ -555,59 +579,35 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
message: 'Starting conversion...',
|
||||
});
|
||||
|
||||
let jobComplete = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 600;
|
||||
let jobComplete = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 600;
|
||||
let pollDelay = 500;
|
||||
|
||||
while (!jobComplete && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
attempts += 1;
|
||||
while (!jobComplete && attempts < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelay));
|
||||
attempts += 1;
|
||||
if (pollDelay < 10000) {
|
||||
pollDelay = Math.min(10000, Math.floor(pollDelay * 1.5));
|
||||
}
|
||||
|
||||
try {
|
||||
const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`);
|
||||
const jobStatus = statusResponse.data;
|
||||
console.log(`Job status (attempt ${attempts}):`, jobStatus);
|
||||
|
||||
if (jobStatus.notes && jobStatus.notes.length > 0) {
|
||||
const lastNote = jobStatus.notes[jobStatus.notes.length - 1];
|
||||
console.log('Latest note:', lastNote);
|
||||
const matchWithCount = lastNote.match(
|
||||
/\[(\d+)%\]\s+(\w+):\s+(.+?)\s+\((\d+)\/(\d+)\)/,
|
||||
);
|
||||
if (matchWithCount) {
|
||||
const percent = parseInt(matchWithCount[1], 10);
|
||||
const stage = matchWithCount[2];
|
||||
const message = matchWithCount[3];
|
||||
const current = parseInt(matchWithCount[4], 10);
|
||||
const total = parseInt(matchWithCount[5], 10);
|
||||
setConversionProgress({
|
||||
percent,
|
||||
stage,
|
||||
message,
|
||||
current,
|
||||
total,
|
||||
});
|
||||
} else {
|
||||
const match = lastNote.match(/\[(\d+)%\]\s+(\w+):\s+(.+)/);
|
||||
if (match) {
|
||||
const percent = parseInt(match[1], 10);
|
||||
const stage = match[2];
|
||||
const message = match[3];
|
||||
setConversionProgress({
|
||||
percent,
|
||||
stage,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (jobStatus.progress !== undefined) {
|
||||
const percent = Math.min(Math.max(jobStatus.progress, 0), 100);
|
||||
setConversionProgress({
|
||||
percent,
|
||||
stage: jobStatus.stage || 'processing',
|
||||
message: jobStatus.note || 'Converting PDF to JSON...',
|
||||
});
|
||||
}
|
||||
const percent = Math.min(Math.max(jobStatus.progress ?? 0, 0), 100);
|
||||
const stage = jobStatus.stage || 'processing';
|
||||
const message = jobStatus.note || 'Converting PDF to JSON...';
|
||||
const current = jobStatus.current ?? undefined;
|
||||
const total = jobStatus.total ?? undefined;
|
||||
setConversionProgress({
|
||||
percent,
|
||||
stage,
|
||||
message,
|
||||
current,
|
||||
total,
|
||||
});
|
||||
|
||||
if (jobStatus.complete) {
|
||||
if (jobStatus.error) {
|
||||
@@ -701,7 +701,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
setLoadedDocument(parsed);
|
||||
resetToDocument(parsed, groupingMode);
|
||||
setIsLazyMode(shouldUseLazyMode);
|
||||
setCachedJobId(shouldUseLazyMode ? pendingJobId : null);
|
||||
const newJobId = shouldUseLazyMode ? pendingJobId : null;
|
||||
setCachedJobId(newJobId);
|
||||
cachedJobIdRef.current = newJobId;
|
||||
setFileName(file.name);
|
||||
setErrorMessage(null);
|
||||
} catch (error: any) {
|
||||
@@ -719,6 +721,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
setLoadedDocument(null);
|
||||
resetToDocument(null, groupingMode);
|
||||
clearPdfPreview();
|
||||
setIsLazyMode(false);
|
||||
setCachedJobId(null);
|
||||
cachedJobIdRef.current = null;
|
||||
|
||||
if (isPdf) {
|
||||
const errorMsg =
|
||||
@@ -743,6 +748,38 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
[groupingMode, resetToDocument, t],
|
||||
);
|
||||
|
||||
const recoverCacheAndReload = useCallback(async () => {
|
||||
if (cacheRecoveryInProgressRef.current) {
|
||||
return false;
|
||||
}
|
||||
if (cacheRecoveryAttemptsRef.current >= 2) {
|
||||
console.warn('[PdfTextEditor] Cache recovery limit reached');
|
||||
return false;
|
||||
}
|
||||
cacheRecoveryAttemptsRef.current += 1;
|
||||
const file = lastLoadedFileRef.current;
|
||||
if (!file) {
|
||||
console.warn('[PdfTextEditor] No file available for cache recovery');
|
||||
return false;
|
||||
}
|
||||
cacheRecoveryInProgressRef.current = true;
|
||||
try {
|
||||
console.log('[PdfTextEditor] Automatically reloading file due to cache expiration...');
|
||||
await handleLoadFile(file);
|
||||
console.log('[PdfTextEditor] Cache recovery successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[PdfTextEditor] Cache recovery failed', error);
|
||||
return false;
|
||||
} finally {
|
||||
cacheRecoveryInProgressRef.current = false;
|
||||
}
|
||||
}, [handleLoadFile]);
|
||||
|
||||
useEffect(() => {
|
||||
recoverCacheAndReloadRef.current = recoverCacheAndReload;
|
||||
}, [recoverCacheAndReload]);
|
||||
|
||||
// Wrapper for loading files from the dropzone - adds to workbench first
|
||||
const handleLoadFileFromDropzone = useCallback(
|
||||
async (file: File) => {
|
||||
@@ -1057,7 +1094,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload) {
|
||||
return;
|
||||
throw new Error('Failed to build payload');
|
||||
}
|
||||
|
||||
const { document, filename } = payload;
|
||||
@@ -1076,7 +1113,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
|
||||
const expectedName = `${baseName || 'document'}.pdf`;
|
||||
const response = await apiClient.post(
|
||||
`/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`,
|
||||
`/api/v1/convert/pdf/text-editor/partial/${cachedJobIdRef.current}?filename=${encodeURIComponent(expectedName)}`,
|
||||
partialDocument,
|
||||
{
|
||||
responseType: 'blob',
|
||||
@@ -1100,6 +1137,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
'[handleGeneratePdf] Incremental export failed, falling back to full export',
|
||||
incrementalError,
|
||||
);
|
||||
// Fall through to full export below
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1636,6 +1674,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
}, 0);
|
||||
}, [navigationActions, navigationState.selectedTool]);
|
||||
|
||||
// Register workbench view (re-runs when dependencies change)
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: WORKBENCH_VIEW_ID,
|
||||
@@ -1646,24 +1685,30 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
setLeftPanelView('hidden');
|
||||
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current);
|
||||
|
||||
return () => {
|
||||
// Clear backend cache if we were using lazy loading
|
||||
clearCachedJob(cachedJobIdRef.current);
|
||||
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
|
||||
setLeftPanelView('toolPicker');
|
||||
};
|
||||
}, [
|
||||
clearCachedJob,
|
||||
clearCustomWorkbenchViewData,
|
||||
registerCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
setLeftPanelView,
|
||||
viewLabel,
|
||||
unregisterCustomWorkbenchView,
|
||||
]);
|
||||
|
||||
// Cleanup ONLY on component unmount (not on re-renders)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear backend cache when leaving the tool
|
||||
const jobId = cachedJobIdRef.current;
|
||||
if (jobId) {
|
||||
console.log(`[PdfTextEditor] Cleaning up cached document on unmount: ${jobId}`);
|
||||
apiClient.post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`).catch((error) => {
|
||||
console.warn('[PdfTextEditor] Failed to clear cache on unmount:', error);
|
||||
});
|
||||
}
|
||||
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
|
||||
setLeftPanelView('toolPicker');
|
||||
};
|
||||
}, []); // Empty deps = cleanup only on unmount
|
||||
|
||||
// Note: Compare tool doesn't auto-force workbench, and neither should we
|
||||
// The workbench should be set when the tool is selected via proper channels
|
||||
// (tool registry, tool picker, etc.) - not forced here
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user