Compare commits

..
121 changed files with 1484 additions and 7106 deletions
@@ -491,9 +491,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Ghostscript", "repair"); addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf"); addEndpointToGroup("Ghostscript", "compress-pdf");
/* ImageMagick */
addEndpointToGroup("ImageMagick", "compress-pdf");
/* tesseract */ /* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf"); addEndpointToGroup("tesseract", "ocr-pdf");
@@ -577,7 +574,6 @@ public class EndpointConfiguration {
|| "Javascript".equals(group) || "Javascript".equals(group)
|| "Weasyprint".equals(group) || "Weasyprint".equals(group)
|| "Pdftohtml".equals(group) || "Pdftohtml".equals(group)
|| "ImageMagick".equals(group)
|| "rar".equals(group); || "rar".equals(group);
} }
@@ -37,6 +37,10 @@ public class AppConfig {
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@Getter
@Value("${baseUrl:http://localhost}")
private String baseUrl;
@Getter @Getter
@Value("${server.servlet.context-path:/}") @Value("${server.servlet.context-path:/}")
private String contextPath; private String contextPath;
@@ -45,17 +49,6 @@ public class AppConfig {
@Value("${server.port:8080}") @Value("${server.port:8080}")
private String serverPort; private String serverPort;
/**
* Get the backend URL from system configuration. Falls back to http://localhost if not
* configured.
*
* @return The backend base URL for SAML/OAuth/API callbacks
*/
public String getBackendUrl() {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
}
@Value("${v2}") @Value("${v2}")
public boolean v2Enabled; public boolean v2Enabled;
@@ -68,7 +68,6 @@ public class ApplicationProperties {
private AutoPipeline autoPipeline = new AutoPipeline(); private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor(); private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
@Bean @Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment) public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -101,46 +100,6 @@ public class ApplicationProperties {
private String outputFolder; 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 @Data
public static class Legal { public static class Legal {
private String termsAndConditions; private String termsAndConditions;
@@ -398,7 +357,6 @@ public class ApplicationProperties {
private Boolean enableAnalytics; private Boolean enableAnalytics;
private Boolean enablePosthog; private Boolean enablePosthog;
private Boolean enableScarf; private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Datasource datasource; private Datasource datasource;
private Boolean disableSanitize; private Boolean disableSanitize;
private int maxDPI; private int maxDPI;
@@ -409,12 +367,10 @@ public class ApplicationProperties {
private TempFileManagement tempFileManagement = new TempFileManagement(); private TempFileManagement tempFileManagement = new TempFileManagement();
private DatabaseBackup databaseBackup = new DatabaseBackup(); private DatabaseBackup databaseBackup = new DatabaseBackup();
private List<String> corsAllowedOrigins = new ArrayList<>(); private List<String> corsAllowedOrigins = new ArrayList<>();
private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g. private String frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
// 'http://localhost:8080', 'https://api.example.com'). Required for private ServerCertificate serverCertificate = new ServerCertificate();
// SSO.
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl. // falls back to backend URL.
public boolean isAnalyticsEnabled() { public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics(); return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -431,6 +387,14 @@ public class ApplicationProperties {
return this.isAnalyticsEnabled() return this.isAnalyticsEnabled()
&& (this.getEnableScarf() == null || this.getEnableScarf()); && (this.getEnableScarf() == null || this.getEnableScarf());
} }
@Data
public static class ServerCertificate {
private boolean enabled = true;
private String organizationName = "Stirling-PDF";
private int validity = 365; // days
private boolean regenerateOnStartup = false;
}
} }
@Data @Data
@@ -579,7 +543,6 @@ public class ApplicationProperties {
@ToString.Exclude private String key; @ToString.Exclude private String key;
private String UUID; private String UUID;
private String appVersion; private String appVersion;
private Boolean isNewServer;
} }
// TODO: Remove post migration // TODO: Remove post migration
@@ -697,7 +660,6 @@ public class ApplicationProperties {
private int weasyPrintSessionLimit; private int weasyPrintSessionLimit;
private int installAppSessionLimit; private int installAppSessionLimit;
private int calibreSessionLimit; private int calibreSessionLimit;
private int imageMagickSessionLimit;
private int qpdfSessionLimit; private int qpdfSessionLimit;
private int tesseractSessionLimit; private int tesseractSessionLimit;
private int ghostscriptSessionLimit; private int ghostscriptSessionLimit;
@@ -735,10 +697,6 @@ public class ApplicationProperties {
return calibreSessionLimit > 0 ? calibreSessionLimit : 1; return calibreSessionLimit > 0 ? calibreSessionLimit : 1;
} }
public int getImageMagickSessionLimit() {
return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4;
}
public int getGhostscriptSessionLimit() { public int getGhostscriptSessionLimit() {
return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8; return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8;
} }
@@ -768,8 +726,6 @@ public class ApplicationProperties {
@JsonProperty("calibretimeoutMinutes") @JsonProperty("calibretimeoutMinutes")
private long calibreTimeoutMinutes; private long calibreTimeoutMinutes;
private long imageMagickTimeoutMinutes;
private long tesseractTimeoutMinutes; private long tesseractTimeoutMinutes;
private long qpdfTimeoutMinutes; private long qpdfTimeoutMinutes;
private long ghostscriptTimeoutMinutes; private long ghostscriptTimeoutMinutes;
@@ -807,10 +763,6 @@ public class ApplicationProperties {
return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30; return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30;
} }
public long getImageMagickTimeoutMinutes() {
return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30;
}
public long getGhostscriptTimeoutMinutes() { public long getGhostscriptTimeoutMinutes() {
return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30; return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30;
} }
@@ -1,12 +0,0 @@
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,11 +86,6 @@ public class ProcessExecutor {
.getProcessExecutor() .getProcessExecutor()
.getSessionLimit() .getSessionLimit()
.getCalibreSessionLimit(); .getCalibreSessionLimit();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getImageMagickSessionLimit();
case GHOSTSCRIPT -> case GHOSTSCRIPT ->
applicationProperties applicationProperties
.getProcessExecutor() .getProcessExecutor()
@@ -146,11 +141,6 @@ public class ProcessExecutor {
.getProcessExecutor() .getProcessExecutor()
.getTimeoutMinutes() .getTimeoutMinutes()
.getCalibreTimeoutMinutes(); .getCalibreTimeoutMinutes();
case IMAGEMAGICK ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getImageMagickTimeoutMinutes();
case GHOSTSCRIPT -> case GHOSTSCRIPT ->
applicationProperties applicationProperties
.getProcessExecutor() .getProcessExecutor()
@@ -311,7 +301,6 @@ public class ProcessExecutor {
WEASYPRINT, WEASYPRINT,
INSTALL_APP, INSTALL_APP,
CALIBRE, CALIBRE,
IMAGEMAGICK,
TESSERACT, TESSERACT,
QPDF, QPDF,
GHOSTSCRIPT, GHOSTSCRIPT,
+3 -15
View File
@@ -268,18 +268,6 @@ tasks.register('cleanFrontendAssets', Delete) {
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) } delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
} }
tasks.register('copyApiLandingPage', Copy) {
group = 'frontend'
description = 'Copy API landing page to index.html for backend-only mode'
from(new File(resourcesStaticDir, 'api-landing.html'))
into(resourcesStaticDir)
rename('api-landing.html', 'index.html')
dependsOn cleanFrontendAssets
doFirst {
println "Copying API landing page to index.html for backend-only mode..."
}
}
// Ensure copyFrontendAssets runs after spotless tasks // Ensure copyFrontendAssets runs after spotless tasks
tasks.named('copyFrontendAssets').configure { tasks.named('copyFrontendAssets').configure {
mustRunAfter tasks.matching { it.name.startsWith('spotless') } mustRunAfter tasks.matching { it.name.startsWith('spotless') }
@@ -289,9 +277,9 @@ if (buildWithFrontend) {
println "Frontend build enabled - JAR will include React frontend" println "Frontend build enabled - JAR will include React frontend"
processResources.dependsOn copyFrontendAssets processResources.dependsOn copyFrontendAssets
} else { } else {
println "Frontend build disabled - JAR will be backend-only with API landing page" println "Frontend build disabled - JAR will be backend-only"
// When not building the UI, ensure any stale frontend assets are removed and use API landing page // When not building the UI, ensure any stale frontend assets are removed
processResources.dependsOn copyApiLandingPage processResources.dependsOn cleanFrontendAssets
} }
bootJar.dependsOn ':common:jar' bootJar.dependsOn ':common:jar'
@@ -138,13 +138,13 @@ public class SPDFApplication {
@PostConstruct @PostConstruct
public void init() { public void init() {
String backendUrl = appConfig.getBackendUrl(); String baseUrl = appConfig.getBaseUrl();
String contextPath = appConfig.getContextPath(); String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort(); String serverPort = appConfig.getServerPort();
baseUrlStatic = backendUrl; baseUrlStatic = baseUrl;
contextPathStatic = contextPath; contextPathStatic = contextPath;
serverPortStatic = serverPort; serverPortStatic = serverPort;
String url = backendUrl + ":" + getStaticPort() + contextPath; String url = baseUrl + ":" + getStaticPort() + contextPath;
// Log Tauri mode information // Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) { if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -46,7 +46,6 @@ public class ExternalAppDepConfig {
put("qpdf", List.of("qpdf")); put("qpdf", List.of("qpdf"));
put("tesseract", List.of("tesseract")); put("tesseract", List.of("tesseract"));
put("rar", List.of("rar")); // Required for real CBR output put("rar", List.of("rar")); // Required for real CBR output
put("magick", List.of("ImageMagick"));
} }
}; };
} }
@@ -129,7 +128,6 @@ public class ExternalAppDepConfig {
checkDependencyAndDisableGroup("pdftohtml"); checkDependencyAndDisableGroup("pdftohtml");
checkDependencyAndDisableGroup(unoconvPath); checkDependencyAndDisableGroup(unoconvPath);
checkDependencyAndDisableGroup("rar"); checkDependencyAndDisableGroup("rar");
checkDependencyAndDisableGroup("magick");
// Special handling for Python/OpenCV dependencies // Special handling for Python/OpenCV dependencies
boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python"); boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python");
if (!pythonAvailable) { if (!pythonAvailable) {
@@ -94,7 +94,6 @@ public class InitialSetup {
} }
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion); GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion); applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer);
} }
public static boolean isNewServer() { public static boolean isNewServer() {
@@ -33,35 +33,14 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year // Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes // 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/**") registry.addResourceHandler("/assets/**")
.addResourceLocations( .addResourceLocations("classpath:/static/assets/")
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath()
+ "assets/",
"classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()); .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets // 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") registry.addResourceHandler("/index.html")
.addResourceLocations( .addResourceLocations("classpath:/static/")
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate()); .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 @Override
@@ -1,60 +0,0 @@
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));
}
}
}
}
@@ -28,13 +28,10 @@ import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject; import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; 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.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -47,7 +44,6 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor; import stirling.software.common.util.ProcessExecutor;
@@ -62,9 +58,6 @@ public class CompressController {
private final CustomPDFDocumentFactory pdfDocumentFactory; private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration; private final EndpointConfiguration endpointConfiguration;
@Autowired(required = false)
private LineArtConversionService lineArtConversionService;
private boolean isQpdfEnabled() { private boolean isQpdfEnabled() {
return endpointConfiguration.isGroupEnabled("qpdf"); return endpointConfiguration.isGroupEnabled("qpdf");
} }
@@ -73,10 +66,6 @@ public class CompressController {
return endpointConfiguration.isGroupEnabled("Ghostscript"); return endpointConfiguration.isGroupEnabled("Ghostscript");
} }
private boolean isImageMagickEnabled() {
return endpointConfiguration.isGroupEnabled("ImageMagick");
}
@Data @Data
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
@@ -671,9 +660,6 @@ public class CompressController {
Integer optimizeLevel = request.getOptimizeLevel(); Integer optimizeLevel = request.getOptimizeLevel();
String expectedOutputSizeString = request.getExpectedOutputSize(); String expectedOutputSizeString = request.getExpectedOutputSize();
Boolean convertToGrayscale = request.getGrayscale(); Boolean convertToGrayscale = request.getGrayscale();
Boolean convertToLineArt = request.getLineArt();
Double lineArtThreshold = request.getLineArtThreshold();
Integer lineArtEdgeLevel = request.getLineArtEdgeLevel();
if (expectedOutputSizeString == null && optimizeLevel == null) { if (expectedOutputSizeString == null && optimizeLevel == null) {
throw new Exception("Both expected output size and optimize level are not specified"); throw new Exception("Both expected output size and optimize level are not specified");
} }
@@ -703,26 +689,6 @@ public class CompressController {
optimizeLevel = determineOptimizeLevel(sizeReductionRatio); 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 sizeMet = false;
boolean imageCompressionApplied = false; boolean imageCompressionApplied = false;
boolean externalCompressionApplied = false; boolean externalCompressionApplied = false;
@@ -844,75 +810,6 @@ 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 // Run Ghostscript compression
private void applyGhostscriptCompression( private void applyGhostscriptCompression(
OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles) OptimizePdfRequest request, int optimizeLevel, Path currentFile, List<Path> tempFiles)
@@ -66,8 +66,7 @@ public class ConfigController {
AppConfig appConfig = applicationContext.getBean(AppConfig.class); AppConfig appConfig = applicationContext.getBean(AppConfig.class);
// Extract key configuration values from AppConfig // Extract key configuration values from AppConfig
// Note: Frontend expects "baseUrl" field name for compatibility configData.put("baseUrl", appConfig.getBaseUrl());
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("contextPath", appConfig.getContextPath()); configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort()); configData.put("serverPort", appConfig.getServerPort());
@@ -125,9 +124,6 @@ public class ConfigController {
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); "enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog()); configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf()); configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
configData.put(
"enableDesktopInstallSlide",
applicationProperties.getSystem().getEnableDesktopInstallSlide());
// Premium/Enterprise settings // Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
@@ -231,10 +227,4 @@ public class ConfigController {
} }
return ResponseEntity.ok(result); 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);
}
} }
@@ -191,12 +191,6 @@ public class CertSignController {
switch (certType) { switch (certType) {
case "PEM": 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 = KeyStore.getInstance("JKS");
ks.load(null); ks.load(null);
PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password); PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password);
@@ -206,16 +200,10 @@ public class CertSignController {
break; break;
case "PKCS12": case "PKCS12":
case "PFX": case "PFX":
p12File =
validateFilePresent(
p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required");
ks = KeyStore.getInstance("PKCS12"); ks = KeyStore.getInstance("PKCS12");
ks.load(p12File.getInputStream(), password.toCharArray()); ks.load(p12File.getInputStream(), password.toCharArray());
break; break;
case "JKS": case "JKS":
jksfile =
validateFilePresent(
jksfile, "JKS keystore", "JKS keystore file is required");
ks = KeyStore.getInstance("JKS"); ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), password.toCharArray()); ks.load(jksfile.getInputStream(), password.toCharArray());
break; break;
@@ -263,17 +251,6 @@ public class CertSignController {
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf")); 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) private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
throws IOException, OperatorCreationException, PKCSException { throws IOException, OperatorCreationException, PKCSException {
try (PEMParser pemParser = try (PEMParser pemParser =
@@ -3,14 +3,9 @@ package stirling.software.SPDF.controller.web;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; 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.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource; 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.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
@@ -19,11 +14,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
@Controller @Controller
public class ReactRoutingController { public class ReactRoutingController {
@@ -32,44 +22,24 @@ public class ReactRoutingController {
private String cachedIndexHtml; private String cachedIndexHtml;
private boolean indexHtmlExists = false; private boolean indexHtmlExists = false;
private boolean useExternalIndexHtml = false;
@PostConstruct @PostConstruct
public void init() { public void init() {
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath()); // Only cache if index.html exists (production builds)
// 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"); ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) { if (resource.exists()) {
try { try {
this.cachedIndexHtml = processIndexHtml(); this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true; this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
} catch (IOException e) { } catch (IOException e) {
// Failed to cache, will process on each request // Failed to cache, will process on each request
log.warn("Failed to cache index.html", e);
this.indexHtmlExists = false; this.indexHtmlExists = false;
} }
} }
} }
private String processIndexHtml() throws IOException { private String processIndexHtml() throws IOException {
Resource resource = getIndexHtmlResource(); ClassPathResource resource = new ClassPathResource("static/index.html");
try (InputStream inputStream = resource.getInputStream()) { try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
@@ -92,17 +62,6 @@ 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( @GetMapping(
value = {"/", "/index.html"}, value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE) produces = MediaType.TEXT_HTML_VALUE)
@@ -115,13 +74,13 @@ public class ReactRoutingController {
} }
@GetMapping( @GetMapping(
"/{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)[^\\.]*$}") "/{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)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException { public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request); return serveIndexHtml(request);
} }
@GetMapping( @GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request) public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException { throws IOException {
return serveIndexHtml(request); return serveIndexHtml(request);
@@ -1,8 +0,0 @@
package stirling.software.SPDF.exception;
public class CacheUnavailableException extends RuntimeException {
public CacheUnavailableException(String message) {
super(message);
}
}
@@ -45,26 +45,4 @@ public class OptimizePdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.REQUIRED, requiredMode = Schema.RequiredMode.REQUIRED,
defaultValue = "false") defaultValue = "false")
private Boolean grayscale = 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;
} }
@@ -86,6 +86,7 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition; import org.apache.pdfbox.text.TextPosition;
import org.apache.pdfbox.util.DateConverter; import org.apache.pdfbox.util.DateConverter;
import org.apache.pdfbox.util.Matrix; import org.apache.pdfbox.util.Matrix;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -143,23 +144,15 @@ public class PdfJsonConversionService {
private final PdfJsonFontService fontService; private final PdfJsonFontService fontService;
private final Type3FontConversionService type3FontConversionService; private final Type3FontConversionService type3FontConversionService;
private final Type3GlyphExtractor type3GlyphExtractor; private final Type3GlyphExtractor type3GlyphExtractor;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
private final Map<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>(); private final Map<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> type3GlyphCoverageCache = new ConcurrentHashMap<>(); private final Map<String, Set<Integer>> type3GlyphCoverageCache = new ConcurrentHashMap<>();
@Value("${stirling.pdf.json.font-normalization.enabled:true}")
private boolean fontNormalizationEnabled; private boolean fontNormalizationEnabled;
private long cacheMaxBytes;
private int cacheMaxPercent;
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */ /** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>(); 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 volatile boolean ghostscriptAvailable;
private static final float FLOAT_EPSILON = 0.0001f; private static final float FLOAT_EPSILON = 0.0001f;
@@ -168,23 +161,7 @@ public class PdfJsonConversionService {
@PostConstruct @PostConstruct
private void initializeToolAvailability() { private void initializeToolAvailability() {
loadConfigurationFromProperties();
initializeGhostscriptAvailability(); 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() { private void initializeGhostscriptAvailability() {
@@ -225,25 +202,6 @@ 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 { public byte[] convertPdfToJson(MultipartFile file) throws IOException {
return convertPdfToJson(file, null, false); return convertPdfToJson(file, null, false);
} }
@@ -278,10 +236,7 @@ public class PdfJsonConversionService {
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId); log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
} else { } else {
jobId = contextJobId; jobId = contextJobId;
log.info( log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId);
"Starting PDF to JSON conversion, jobId from context: {} (lightweight={})",
jobId,
lightweight);
} }
Consumer<PdfJsonConversionProgress> progress = Consumer<PdfJsonConversionProgress> progress =
@@ -363,9 +318,9 @@ public class PdfJsonConversionService {
try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) { try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) {
int totalPages = document.getNumberOfPages(); int totalPages = document.getNumberOfPages();
// Always enable lazy mode for real async jobs so cache is available regardless of // Only use lazy images for real async jobs where client can access the cache
// page count. Synchronous calls with synthetic jobId still do full extraction. // Synchronous calls with synthetic jobId should do full extraction
boolean useLazyImages = isRealJobId; boolean useLazyImages = totalPages > 5 && isRealJobId;
Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>(); Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>(); Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
log.debug( log.debug(
@@ -448,11 +403,6 @@ public class PdfJsonConversionService {
// Only cache for real async jobIds, not synthetic synchronous ones // Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) { if (useLazyImages && isRealJobId) {
log.info(
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
jobId,
useLazyImages,
isRealJobId);
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata()); docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata()); docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
@@ -485,23 +435,16 @@ public class PdfJsonConversionService {
cachedPdfBytes = Files.readAllBytes(workingPath); cachedPdfBytes = Files.readAllBytes(workingPath);
} }
CachedPdfDocument cached = CachedPdfDocument cached =
buildCachedDocument( new CachedPdfDocument(
jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources); cachedPdfBytes, docMetadata, fonts, pageFontResources);
putCachedDocument(jobId, cached); documentCache.put(jobId, cached);
log.info( log.debug(
"Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})", "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}",
cached.getPdfSize(), cachedPdfBytes.length,
totalPages, totalPages,
fonts.size(), fonts.size(),
jobId,
cached.isDiskBacked());
scheduleDocumentCleanup(jobId);
} else {
log.warn(
"Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
useLazyImages,
isRealJobId,
jobId); jobId);
scheduleDocumentCleanup(jobId);
} }
if (lightweight) { if (lightweight) {
@@ -3030,139 +2973,6 @@ 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) private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element)
throws IOException { throws IOException {
if (element.getCharacterSpacing() != null) { if (element.getCharacterSpacing() != null) {
@@ -5501,8 +5311,6 @@ public class PdfJsonConversionService {
*/ */
private static class CachedPdfDocument { private static class CachedPdfDocument {
private final byte[] pdfBytes; private final byte[] pdfBytes;
private final TempFile pdfTempFile;
private final long pdfSize;
private final PdfJsonDocumentMetadata metadata; private final PdfJsonDocumentMetadata metadata;
private final Map<String, PdfJsonFont> fonts; // Font map with UIDs for consistency private final Map<String, PdfJsonFont> fonts; // Font map with UIDs for consistency
private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources
@@ -5510,14 +5318,10 @@ public class PdfJsonConversionService {
public CachedPdfDocument( public CachedPdfDocument(
byte[] pdfBytes, byte[] pdfBytes,
TempFile pdfTempFile,
long pdfSize,
PdfJsonDocumentMetadata metadata, PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts, Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources) { Map<Integer, Map<PDFont, String>> pageFontResources) {
this.pdfBytes = pdfBytes; this.pdfBytes = pdfBytes;
this.pdfTempFile = pdfTempFile;
this.pdfSize = pdfSize;
this.metadata = metadata; this.metadata = metadata;
// Create defensive copies to prevent mutation of shared maps // Create defensive copies to prevent mutation of shared maps
this.fonts = this.fonts =
@@ -5532,14 +5336,8 @@ public class PdfJsonConversionService {
} }
// Getters return defensive copies to prevent external mutation // Getters return defensive copies to prevent external mutation
public byte[] getPdfBytes() throws IOException { public byte[] getPdfBytes() {
if (pdfBytes != null) { return pdfBytes;
return pdfBytes;
}
if (pdfTempFile != null) {
return Files.readAllBytes(pdfTempFile.getPath());
}
throw new IOException("Cached PDF backing missing");
} }
public PdfJsonDocumentMetadata getMetadata() { public PdfJsonDocumentMetadata getMetadata() {
@@ -5554,18 +5352,6 @@ public class PdfJsonConversionService {
return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources); 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() { public long getTimestamp() {
return timestamp; return timestamp;
} }
@@ -5577,19 +5363,7 @@ public class PdfJsonConversionService {
public CachedPdfDocument withUpdatedFonts( public CachedPdfDocument withUpdatedFonts(
byte[] nextBytes, Map<String, PdfJsonFont> nextFonts) { byte[] nextBytes, Map<String, PdfJsonFont> nextFonts) {
Map<String, PdfJsonFont> fontsToUse = nextFonts != null ? nextFonts : this.fonts; Map<String, PdfJsonFont> fontsToUse = nextFonts != null ? nextFonts : this.fonts;
return new CachedPdfDocument( return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources);
nextBytes,
null,
nextBytes != null ? nextBytes.length : 0,
metadata,
fontsToUse,
pageFontResources);
}
public void close() {
if (pdfTempFile != null) {
pdfTempFile.close();
}
} }
} }
@@ -5670,15 +5444,14 @@ public class PdfJsonConversionService {
// Cache PDF bytes, metadata, and fonts for lazy page loading // Cache PDF bytes, metadata, and fonts for lazy page loading
if (jobId != null) { if (jobId != null) {
CachedPdfDocument cached = CachedPdfDocument cached =
buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources); new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources);
putCachedDocument(jobId, cached); documentCache.put(jobId, cached);
log.debug( log.debug(
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})", "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}",
cached.getPdfSize(), pdfBytes.length,
totalPages, totalPages,
fonts.size(), fonts.size(),
jobId, jobId);
cached.isDiskBacked());
// Schedule cleanup after 30 minutes // Schedule cleanup after 30 minutes
scheduleDocumentCleanup(jobId); scheduleDocumentCleanup(jobId);
@@ -5693,10 +5466,9 @@ public class PdfJsonConversionService {
/** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */ /** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */
public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException { public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException {
CachedPdfDocument cached = getCachedDocument(jobId); CachedPdfDocument cached = documentCache.get(jobId);
if (cached == null) { if (cached == null) {
throw new stirling.software.SPDF.exception.CacheUnavailableException( throw new IllegalArgumentException("No cached document found for jobId: " + jobId);
"No cached document found for jobId: " + jobId);
} }
int pageIndex = pageNumber - 1; int pageIndex = pageNumber - 1;
@@ -5708,8 +5480,8 @@ public class PdfJsonConversionService {
} }
log.debug( log.debug(
"Loading PDF from {} to extract page {} (jobId: {})", "Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})",
cached.isDiskBacked() ? "disk cache" : "memory cache", cached.getPdfBytes().length,
pageNumber, pageNumber,
jobId); jobId);
@@ -5855,21 +5627,10 @@ public class PdfJsonConversionService {
if (jobId == null || jobId.isBlank()) { if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export"); throw new IllegalArgumentException("jobId is required for incremental export");
} }
log.info("Looking up cache for jobId: {}", jobId); CachedPdfDocument cached = documentCache.get(jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) { if (cached == null) {
log.error( throw new IllegalArgumentException("No cached document available for jobId: " + jobId);
"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()) { if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) {
log.debug( log.debug(
"Incremental export requested with no page updates; returning cached PDF for jobId {}", "Incremental export requested with no page updates; returning cached PDF for jobId {}",
@@ -5948,14 +5709,7 @@ public class PdfJsonConversionService {
document.save(baos); document.save(baos);
byte[] updatedBytes = baos.toByteArray(); byte[] updatedBytes = baos.toByteArray();
CachedPdfDocument updated = documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts));
buildCachedDocument(
jobId,
updatedBytes,
cached.getMetadata(),
mergedFonts,
cached.getPageFontResources());
putCachedDocument(jobId, updated);
// Clear Type3 cache entries for this incremental update // Clear Type3 cache entries for this incremental update
clearType3CacheEntriesForJob(updateJobId); clearType3CacheEntriesForJob(updateJobId);
@@ -5970,13 +5724,11 @@ public class PdfJsonConversionService {
/** Clears a cached document. */ /** Clears a cached document. */
public void clearCachedDocument(String jobId) { public void clearCachedDocument(String jobId) {
CachedPdfDocument cached = getCachedDocument(jobId); CachedPdfDocument cached = documentCache.remove(jobId);
removeCachedDocument(jobId);
if (cached != null) { if (cached != null) {
log.debug( log.debug(
"Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}", "Removed cached PDF bytes ({} bytes) for jobId: {}",
cached.getPdfSize(), cached.getPdfBytes().length,
cached.isDiskBacked(),
jobId); jobId);
} }
@@ -312,29 +312,12 @@ public class PdfJsonFallbackFontService {
"ttf"))); "ttf")));
private final ResourceLoader resourceLoader; private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}") @Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
private String legacyFallbackFontLocation;
private String fallbackFontLocation; private String fallbackFontLocation;
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>(); 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 { public PdfJsonFont buildFallbackFontModel() throws IOException {
return buildFallbackFontModel(FALLBACK_FONT_ID); return buildFallbackFontModel(FALLBACK_FONT_ID);
} }
@@ -5,6 +5,7 @@ import java.nio.file.Files;
import java.util.Base64; import java.util.Base64;
import java.util.Locale; import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -24,16 +25,22 @@ import stirling.software.common.util.TempFileManager;
public class PdfJsonFontService { public class PdfJsonFontService {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Getter private boolean cffConversionEnabled; @Getter
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
private boolean cffConversionEnabled;
@Getter private String cffConverterMethod; @Getter
@Value("${stirling.pdf.json.cff-converter.method:python}")
private String cffConverterMethod;
@Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
private String pythonCommand; private String pythonCommand;
@Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
private String pythonScript; private String pythonScript;
@Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
private String fontforgeCommand; private String fontforgeCommand;
private volatile boolean pythonCffConverterAvailable; private volatile boolean pythonCffConverterAvailable;
@@ -41,7 +48,6 @@ public class PdfJsonFontService {
@PostConstruct @PostConstruct
private void initialiseCffConverterAvailability() { private void initialiseCffConverterAvailability() {
loadConfiguration();
if (!cffConversionEnabled) { if (!cffConversionEnabled) {
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration"); log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
pythonCffConverterAvailable = false; pythonCffConverterAvailable = false;
@@ -71,22 +77,6 @@ public class PdfJsonFontService {
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod); 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) { public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) { if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
log.warn( log.warn(
@@ -2,6 +2,7 @@ package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException; import java.io.IOException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -22,8 +23,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl
public class Type3LibraryStrategy implements Type3ConversionStrategy { public class Type3LibraryStrategy implements Type3ConversionStrategy {
private final Type3FontLibrary fontLibrary; private final Type3FontLibrary fontLibrary;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.json.type3.library.enabled:true}")
private boolean enabled; private boolean enabled;
@Override @Override
@@ -41,19 +42,6 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
return enabled && fontLibrary != null && fontLibrary.isLoaded(); 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 @Override
public PdfJsonFontConversionCandidate convert( public PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException { Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
@@ -14,6 +14,7 @@ import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.font.PDType3Font; 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.Resource;
import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -33,8 +34,8 @@ public class Type3FontLibrary {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader; 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 String indexLocation;
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>(); private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
@@ -43,17 +44,6 @@ public class Type3FontLibrary {
@jakarta.annotation.PostConstruct @jakarta.annotation.PostConstruct
void initialise() { 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); Resource resource = resourceLoader.getResource(indexLocation);
if (!resource.exists()) { if (!resource.exists()) {
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation); log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
@@ -58,8 +58,6 @@ security:
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider 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 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 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. 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 persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyRotation: true # Set to 'true' to enable key pair rotation
@@ -128,15 +126,13 @@ system:
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files 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. 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 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 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 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 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) 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 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. For local development with frontend on port 5173, add 'http://localhost:5173' corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
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: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL.
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: serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates organizationName: Stirling-PDF # Organization name for generated certificates
@@ -182,6 +178,23 @@ system:
databaseBackup: databaseBackup:
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight 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: ui:
appNameNavbar: '' # name displayed on the navigation bar appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
@@ -210,7 +223,6 @@ processExecutor:
weasyPrintSessionLimit: 16 weasyPrintSessionLimit: 16
installAppSessionLimit: 1 installAppSessionLimit: 1
calibreSessionLimit: 1 calibreSessionLimit: 1
imageMagickSessionLimit: 4
ghostscriptSessionLimit: 8 ghostscriptSessionLimit: 8
ocrMyPdfSessionLimit: 2 ocrMyPdfSessionLimit: 2
timeoutMinutes: # Process executor timeout in minutes timeoutMinutes: # Process executor timeout in minutes
@@ -220,26 +232,7 @@ processExecutor:
weasyPrinttimeoutMinutes: 30 weasyPrinttimeoutMinutes: 30
installApptimeoutMinutes: 60 installApptimeoutMinutes: 60
calibretimeoutMinutes: 30 calibretimeoutMinutes: 30
imageMagickTimeoutMinutes: 30
tesseractTimeoutMinutes: 30 tesseractTimeoutMinutes: 30
qpdfTimeoutMinutes: 30 qpdfTimeoutMinutes: 30
ghostscriptTimeoutMinutes: 30 ghostscriptTimeoutMinutes: 30
ocrMyPdfTimeoutMinutes: 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:)
@@ -1,268 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="description" content="Stirling-PDF API Server">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stirling-PDF - API Server</title>
<!-- Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<link rel="shortcut icon" href="favicon.ico">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f3f4f6;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1.5rem 0;
overflow: auto;
}
.auth-card {
width: min(45rem, 96vw);
background-color: #ffffff;
border-radius: 1.25rem;
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-content {
max-width: 26.25rem;
width: 100%;
}
.login-header {
margin-bottom: 1.5rem;
margin-top: 0.5rem;
}
.login-header-logos {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.login-logo-icon {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
}
.login-logo-text {
height: 2rem;
}
.login-title {
font-size: 2rem;
font-weight: 800;
color: #111827;
margin: 0 0 0.375rem;
}
.login-subtitle {
color: #6b7280;
font-size: 0.875rem;
margin: 0;
}
.section {
margin: 1.5rem 0 0.75rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
}
.section-text {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.5;
margin-bottom: 0.75rem;
}
.section-list {
font-size: 0.875rem;
color: #6b7280;
line-height: 1.6;
margin-left: 1.25rem;
margin-bottom: 0.75rem;
}
.section-list li {
margin-bottom: 0.5rem;
}
.section-list a,
.section-text a {
color: #AF3434;
text-decoration: none;
font-weight: 500;
}
.section-list a:hover,
.section-text a:hover {
text-decoration: underline;
}
.code-inline {
background-color: #f3f4f6;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.8125rem;
color: #374151;
}
.cta-button {
width: 100%;
padding: 0.75rem 1rem;
background-color: #AF3434;
color: white;
border: none;
border-radius: 0.625rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
text-align: center;
transition: background-color 0.2s;
margin: 0.75rem 0;
}
.cta-button:hover {
background-color: #9a2e2e;
}
.divider {
margin: 1.5rem 0;
border: 0;
border-top: 1px solid #e5e7eb;
}
.footer-link {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
display: block;
margin-top: 1rem;
text-decoration: none;
}
.footer-link:hover {
color: #374151;
text-decoration: underline;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
text-align: center;
padding: 1rem;
font-size: 0.875rem;
color: #6b7280;
background-color: transparent;
z-index: 10;
}
.footer a {
color: #AF3434;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="auth-card">
<div class="auth-content">
<div class="login-header">
<div class="login-header-logos">
<img src="favicon.svg" alt="Stirling PDF" class="login-logo-icon">
<img src="api-wordmark.svg" alt="Stirling PDF" class="login-logo-text">
</div>
<h1 class="login-title">API Server</h1>
<p class="login-subtitle">Backend-only mode without web UI</p>
</div>
<div class="section">
<p class="section-text">
This Stirling-PDF instance is running in <strong>API-only mode</strong>. The web interface has not been included in this build.
</p>
</div>
<a href="swagger-ui/index.html" class="cta-button">Open API Documentation</a>
<hr class="divider">
<div class="section">
<h2 class="section-title">Looking for the Web UI?</h2>
<p class="section-text"><strong>If you're using Docker:</strong></p>
<ul class="section-list">
<li>You may have pulled an <strong>API-only image</strong> or there was a build configuration issue</li>
<li>Use the standard Docker image: <code class="code-inline">stirlingtools/stirling-pdf:latest</code></li>
</ul>
<p class="section-text"><strong>If you're using a JAR file:</strong></p>
<ul class="section-list">
<li>You downloaded <code class="code-inline">Stirling-PDF-server.jar</code> which is the <strong>API-only version without UI</strong></li>
<li>Download the full version from <a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" target="_blank">GitHub Releases</a>:</li>
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF.jar</code> - Standard version with UI</li>
<li style="margin-left: 1.5rem;"><code class="code-inline">Stirling-PDF-with-login.jar</code> - Version with authentication features</li>
</ul>
<p class="section-text"><strong>If you built from source:</strong></p>
<ul class="section-list">
<li>Rebuild with: <code class="code-inline">./gradlew build -PbuildWithFrontend=true</code></li>
<li>Or deploy the frontend separately from the <code class="code-inline">/frontend</code> directory</li>
</ul>
</div>
<hr class="divider">
<div class="section">
<h2 class="section-title">Need Help?</h2>
<p class="section-text">Join our community for support:</p>
<ul class="section-list">
<li><a href="https://discord.gg/Cn8pWhQRxZ" target="_blank">Discord Community</a> - Get help from the community</li>
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF/issues" target="_blank">GitHub Issues</a> - Report bugs or request features</li>
<li><a href="https://github.com/Stirling-Tools/Stirling-PDF" target="_blank">GitHub Repository</a> - View documentation and source code</li>
</ul>
</div>
</div>
</div>
<div class="footer">
<span>Powered by </span>
<a href="https://stirlingpdf.com">Stirling PDF</a>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

@@ -1,10 +1,9 @@
package stirling.software.SPDF.controller.api.security; package stirling.software.SPDF.controller.api.security;
import static org.junit.jupiter.api.Assertions.assertNotNull; 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
@@ -108,8 +107,7 @@ class CertSignControllerTest {
derCertBytes = baos.toByteArray(); derCertBytes = baos.toByteArray();
} }
lenient() when(pdfDocumentFactory.load(any(MultipartFile.class)))
.when(pdfDocumentFactory.load(any(MultipartFile.class)))
.thenAnswer( .thenAnswer(
invocation -> { invocation -> {
MultipartFile file = invocation.getArgument(0); MultipartFile file = invocation.getArgument(0);
@@ -169,31 +167,6 @@ class CertSignControllerTest {
assertTrue(response.getBody().length > 0); 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 @Test
void testSignPdfWithJks() throws Exception { void testSignPdfWithJks() throws Exception {
MockMultipartFile pdfFile = MockMultipartFile pdfFile =
@@ -94,22 +94,6 @@ public class ProprietaryUIDataController {
this.auditRepository = auditRepository; 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") @GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint @EnterpriseEndpoint
@@ -201,17 +185,14 @@ public class ProprietaryUIDataController {
} }
SAML2 saml2 = securityProps.getSaml2(); SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider(); String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); 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()) { if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
providerList.put(fullSamlPath, samlIdp + " (SAML 2)"); providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)");
} }
} }
@@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
@@ -33,6 +34,7 @@ import stirling.software.proprietary.service.SignatureService;
* authentication and enforces per-user storage limits. All endpoints require authentication * authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()"). * via @PreAuthorize("isAuthenticated()").
*/ */
@UserApi
@Slf4j @Slf4j
@RestController @RestController
@RequestMapping("/api/v1/proprietary/signatures") @RequestMapping("/api/v1/proprietary/signatures")
@@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
private SamlClient getSamlClient( private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates) String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException { throws SamlException {
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort(); String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier = String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId; serverUrl + "/saml2/service-provider-metadata/" + registrationId;
@@ -120,7 +120,9 @@ public class AccountWebController {
SAML2 saml2 = securityProps.getSaml2(); SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { if (securityProps.isSaml2Active()
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider(); String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
@@ -334,8 +334,7 @@ public class SecurityConfiguration {
securityProperties.getSaml2(), securityProperties.getSaml2(),
userService, userService,
jwtService, jwtService,
licenseSettingsService, licenseSettingsService))
applicationProperties))
.failureHandler( .failureHandler(
new CustomSaml2AuthenticationFailureHandler()) new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver( .authenticationRequestResolver(
@@ -344,8 +343,7 @@ public class SecurityConfiguration {
log.error("Error configuring SAML 2 login", e); log.error("Error configuring SAML 2 login", e);
throw new RuntimeException(e); throw new RuntimeException(e);
} }
}) });
.saml2Metadata(metadata -> {});
} }
} else { } else {
log.debug("Login is not enabled."); log.debug("Login is not enabled.");
@@ -244,13 +244,10 @@ public class AuthController {
userMap.put("username", user.getUsername()); userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString()); userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled()); userMap.put("enabled", user.isEnabled());
userMap.put(
"authenticationType",
user.getAuthenticationType()); // Expose authentication type for SSO detection
// Add metadata for OAuth compatibility // Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>(); Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
userMap.put("app_metadata", appMetadata); userMap.put("app_metadata", appMetadata);
return userMap; return userMap;
@@ -51,7 +51,6 @@ public class CustomSaml2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService; private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService; licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override @Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -78,8 +77,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn( log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username); username);
String origin = resolveOrigin(request); response.sendRedirect(
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); request.getContextPath() + "/logout?saml2RequiresLicense=true");
return; return;
} }
} else if (!licenseSettingsService.isSamlEligible(null)) { } else if (!licenseSettingsService.isSamlEligible(null)) {
@@ -87,8 +86,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn( log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username); username);
String origin = resolveOrigin(request); response.sendRedirect(
response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); request.getContextPath() + "/logout?saml2RequiresLicense=true");
return; return;
} }
@@ -145,28 +144,20 @@ public class CustomSaml2AuthenticationSuccessHandler
log.debug( log.debug(
"User {} exists with password but is not SSO user, redirecting to logout", "User {} exists with password but is not SSO user, redirecting to logout",
username); username);
String origin = resolveOrigin(request); response.sendRedirect(
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true"); contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
return; return;
} }
try { try {
// Block new users only if: blockRegistration is true OR autoCreateUser is false if (!userExists || saml2Properties.getBlockRegistration()) {
if (!userExists log.debug("Registration blocked for new user: {}", username);
&& (saml2Properties.getBlockRegistration() response.sendRedirect(
|| !saml2Properties.getAutoCreateUser())) { contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
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; return;
} }
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) { if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
String origin = resolveOrigin(request); response.sendRedirect(contextPath + "/logout?maxUsersReached=true");
response.sendRedirect(origin + "/logout?maxUsersReached=true");
return; return;
} }
@@ -231,30 +222,16 @@ public class CustomSaml2AuthenticationSuccessHandler
String contextPath, String contextPath,
String jwt) { String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath); String redirectPath = resolveRedirectPath(request, contextPath);
String origin = resolveOrigin(request); String origin =
resolveForwardedOrigin(request)
.orElseGet(
() ->
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
clearRedirectCookie(response); clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt; 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) { private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request) return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/")) .filter(path -> path.startsWith("/"))
@@ -41,92 +41,25 @@ public class Saml2Configuration {
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); 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); Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
// Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey(); Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert(); Resource certificateResource = samlConf.getSpCert();
Saml2X509Credential signingCredential =
log.info("Loading SP private key from: {}", privateKeyResource.getDescription()); new Saml2X509Credential(
if (!privateKeyResource.exists()) { CertificateUtils.readPrivateKey(privateKeyResource),
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription()); CertificateUtils.readCertificate(certificateResource),
throw new IllegalStateException( Saml2X509CredentialType.SIGNING);
"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);
}
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
if (backendUrl == null || backendUrl.isBlank()) {
backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution
log.warn(
"system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use.");
} else {
log.info("Using configured backend URL for SAML: {}", backendUrl);
}
String entityId =
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
String sloResponseLocation = backendUrl + "/login";
RelyingPartyRegistration rp = RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential)) .signingX509Credentials(c -> c.add(signingCredential))
.entityId(entityId) .entityId(samlConf.getIdpIssuer())
.singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(sloResponseLocation) .singleLogoutServiceResponseLocation("http://localhost:8080/login")
.assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(acsLocation) .assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}")
.authnRequestsSigned(true) .authnRequestsSigned(true)
.assertingPartyMetadata( .assertingPartyMetadata(
metadata -> metadata ->
@@ -142,14 +75,9 @@ public class Saml2Configuration {
.singleLogoutServiceLocation( .singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl()) samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation( .singleLogoutServiceResponseLocation(
sloResponseLocation) "http://localhost:8080/login")
.wantAuthnRequestsSigned(true)) .wantAuthnRequestsSigned(true))
.build(); .build();
log.info(
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
samlConf.getRegistrationId(),
samlConf.getIdpIssuer());
return new InMemoryRelyingPartyRegistrationRepository(rp); return new InMemoryRelyingPartyRegistrationRepository(rp);
} }
@@ -1,81 +0,0 @@
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);
}
}
}
@@ -177,13 +177,6 @@ public class UserLicenseSettingsService {
*/ */
@Transactional @Transactional
public void grandfatherExistingOAuthUsers() { 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(); UserLicenseSettings settings = getOrCreateSettings();
// Check if we've already run this migration // Check if we've already run this migration
@@ -355,22 +348,30 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "<new user>"; String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username); log.info("OAuth eligibility check for user: {}", username);
// Check license first - if paying, they're eligible (no need to check grandfathering) // Grandfathered users always have OAuth access
boolean hasPaid = hasPaidLicense();
if (hasPaid) {
log.debug("User {} eligible for OAuth via paid license", username);
return true;
}
// No license - check if grandfathered (fallback for V1 users)
if (user != null && user.isOauthGrandfathered()) { if (user != null && user.isOauthGrandfathered()) {
log.info("User {} eligible for OAuth via grandfathering (no paid license)", username); log.debug("User {} is grandfathered for OAuth", user.getUsername());
return true; return true;
} }
// Not grandfathered and no license // todo: remove
log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username); if (user != null) {
return false; log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting OAuth login - checking license requirement");
}
// 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;
} }
/** /**
@@ -390,26 +391,29 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "<new user>"; String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username); log.info("SAML2 eligibility check for user: {}", username);
// Check license first - if paying, they're eligible (no need to check grandfathering) // Grandfathered users always have SAML access
boolean hasEnterprise = hasEnterpriseLicense();
if (hasEnterprise) {
log.debug("User {} eligible for SAML2 via ENTERPRISE license", username);
return true;
}
// No license - check if grandfathered (fallback for V1 users)
if (user != null && user.isOauthGrandfathered()) { if (user != null && user.isOauthGrandfathered()) {
log.info( log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
"User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)",
username);
return true; return true;
} }
// Not grandfathered and no license if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting SAML2 login - checking license requirement");
}
// Users can use SAML only with ENTERPRISE license
boolean hasEnterprise = hasEnterpriseLicense();
log.info( log.info(
"User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered", "SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
username); hasEnterprise,
return false; username,
hasEnterprise);
return hasEnterprise;
} }
/** /**
@@ -33,7 +33,6 @@ class UserLicenseSettingsServiceTest {
@Mock private UserService userService; @Mock private UserService userService;
@Mock private ApplicationProperties applicationProperties; @Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.Premium premium; @Mock private ApplicationProperties.Premium premium;
@Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated;
@Mock private LicenseKeyChecker licenseKeyChecker; @Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider; @Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
@@ -50,9 +49,6 @@ class UserLicenseSettingsServiceTest {
mockSettings.setGrandfatheredUserSignature("80:test-signature"); mockSettings.setGrandfatheredUserSignature("80:test-signature");
when(applicationProperties.getPremium()).thenReturn(premium); 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(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L); when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class))) when(settingsRepository.save(any(UserLicenseSettings.class)))
+1 -1
View File
@@ -59,7 +59,7 @@ repositories {
allprojects { allprojects {
group = 'stirling.software' group = 'stirling.software'
version = '2.1.5' version = '2.1.3'
configurations.configureEach { configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging' exclude group: 'commons-logging', module: 'commons-logging'
-1
View File
@@ -105,7 +105,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
gcompat \ gcompat \
libc6-compat \ libc6-compat \
libreoffice \ libreoffice \
imagemagick \
# pdftohtml # pdftohtml
poppler-utils \ poppler-utils \
# OCR MY PDF # OCR MY PDF
-1
View File
@@ -81,7 +81,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \ libc6-compat \
libreoffice \ libreoffice \
ghostscript \ ghostscript \
imagemagick \
fontforge \ fontforge \
# pdftohtml # pdftohtml
poppler-utils \ poppler-utils \
-1
View File
@@ -74,7 +74,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \ libc6-compat \
libreoffice \ libreoffice \
ghostscript \ ghostscript \
imagemagick \
fontforge \ fontforge \
# pdftohtml # pdftohtml
poppler-utils \ poppler-utils \
-1
View File
@@ -99,7 +99,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \ libc6-compat \
libreoffice \ libreoffice \
ghostscript \ ghostscript \
imagemagick \
fontforge \ fontforge \
# pdftohtml # pdftohtml
poppler-utils \ poppler-utils \
-1
View File
@@ -101,7 +101,6 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
libc6-compat \ libc6-compat \
libreoffice \ libreoffice \
ghostscript \ ghostscript \
imagemagick \
fontforge \ fontforge \
# pdftohtml # pdftohtml
poppler-utils \ poppler-utils \
+12 -130
View File
@@ -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" ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro"
[analytics] [analytics]
title = "Do you want to help make Stirling PDF better?" 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." 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." paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
learnMore = "Learn more about our analytics" learnMore = "Learn more"
enable = "Enable analytics" enable = "Enable analytics"
disable = "Disable analytics" disable = "Disable analytics"
settings = "You can change the settings for analytics in the config/settings.yml file" settings = "You can change the settings for analytics in the config/settings.yml file"
@@ -340,10 +340,6 @@ advance = "Advanced"
edit = "View & Edit" edit = "View & Edit"
popular = "Popular" popular = "Popular"
[footer]
discord = "Discord"
issues = "GitHub"
[settings.preferences] [settings.preferences]
title = "Preferences" title = "Preferences"
@@ -449,7 +445,6 @@ required = "All fields are required."
mismatch = "New passwords do not match." mismatch = "New passwords do not match."
error = "Unable to update password. Please verify your current password and try again." error = "Unable to update password. Please verify your current password and try again."
success = "Password updated successfully. Please sign in again." success = "Password updated successfully. Please sign in again."
ssoDisabled = "Password changes are managed by your identity provider."
current = "Current password" current = "Current password"
currentPlaceholder = "Enter your current password" currentPlaceholder = "Enter your current password"
new = "New password" new = "New password"
@@ -511,7 +506,6 @@ low = "Low"
title = "Change Credentials" title = "Change Credentials"
header = "Update Your Account Details" header = "Update Your Account Details"
changePassword = "You are using default login credentials. Please enter a new password" changePassword = "You are using default login credentials. Please enter a new password"
ssoManaged = "Your account is managed by your identity provider."
newUsername = "New Username" newUsername = "New Username"
oldPassword = "Current Password" oldPassword = "Current Password"
newPassword = "New Password" newPassword = "New Password"
@@ -736,11 +730,6 @@ tags = "signature,autograph"
title = "Sign" title = "Sign"
desc = "Adds signature to PDF by drawing, text or image" desc = "Adds signature to PDF by drawing, text or image"
[home.annotate]
tags = "annotate,highlight,draw"
title = "Annotate"
desc = "Highlight, draw, add notes and shapes in the viewer"
[home.flatten] [home.flatten]
tags = "simplify,remove,interactive" tags = "simplify,remove,interactive"
title = "Flatten" title = "Flatten"
@@ -3736,16 +3725,6 @@ filesize = "File Size"
[compress.grayscale] [compress.grayscale]
label = "Apply Grayscale for Compression" 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] [compress.tooltip.header]
title = "Compress Settings Overview" title = "Compress Settings Overview"
@@ -3763,10 +3742,6 @@ bullet2 = "Higher values reduce file size"
title = "Grayscale" 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." 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] [compress.error]
failed = "An error occurred while compressing the PDF." failed = "An error occurred while compressing the PDF."
@@ -4018,92 +3993,23 @@ deleteSelected = "Delete Selected Pages"
closePdf = "Close PDF" closePdf = "Close PDF"
exportAll = "Export PDF" exportAll = "Export PDF"
downloadSelected = "Download Selected Files" downloadSelected = "Download Selected Files"
annotations = "Annotations" downloadAll = "Download All"
exportSelected = "Export Selected Pages" saveAll = "Save All"
saveChanges = "Save Changes"
toggleTheme = "Toggle Theme" toggleTheme = "Toggle Theme"
toggleBookmarks = "Toggle Bookmarks"
language = "Language" language = "Language"
toggleAnnotations = "Toggle Annotations Visibility"
search = "Search PDF" search = "Search PDF"
panMode = "Pan Mode" panMode = "Pan Mode"
rotateLeft = "Rotate Left" rotateLeft = "Rotate Left"
rotateRight = "Rotate Right" rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar" toggleSidebar = "Toggle Sidebar"
toggleBookmarks = "Toggle Bookmarks" exportSelected = "Export Selected Pages"
toggleAnnotations = "Toggle Annotations Visibility"
annotationMode = "Toggle Annotation Mode"
print = "Print PDF" print = "Print PDF"
downloadAll = "Download All" draw = "Draw"
saveAll = "Save All" save = "Save"
saveChanges = "Save Changes"
[textAlign]
left = "Left"
center = "Center"
right = "Right"
[annotation]
title = "Annotate"
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
highlight = "Highlight"
pen = "Pen"
text = "Text box"
note = "Note"
rectangle = "Rectangle"
ellipse = "Ellipse"
select = "Select"
exit = "Exit annotation mode"
strokeWidth = "Width"
opacity = "Opacity"
strokeOpacity = "Stroke Opacity"
fillOpacity = "Fill Opacity"
fontSize = "Font size"
chooseColor = "Choose colour"
color = "Colour"
strokeColor = "Stroke Colour"
fillColor = "Fill Colour"
underline = "Underline"
strikeout = "Strikeout"
squiggly = "Squiggly"
inkHighlighter = "Freehand Highlighter"
freehandHighlighter = "Freehand Highlighter"
square = "Square"
circle = "Circle"
polygon = "Polygon"
line = "Line"
stamp = "Add Image"
textMarkup = "Text Markup"
drawing = "Drawing"
shapes = "Shapes"
notesStamps = "Notes & Stamps"
settings = "Settings"
borderOn = "Border: On"
borderOff = "Border: Off"
editInk = "Edit Pen"
editLine = "Edit Line"
editNote = "Edit Note"
editText = "Edit Text Box"
editTextMarkup = "Edit Text Markup"
editSelected = "Edit Annotation"
editSquare = "Edit Square"
editCircle = "Edit Circle"
editPolygon = "Edit Polygon"
unsupportedType = "This annotation type is not fully supported for editing."
textAlignment = "Text Alignment"
noteIcon = "Note Icon"
imagePreview = "Preview"
contents = "Text"
backgroundColor = "Background colour"
clearBackground = "Remove background"
noBackground = "No background"
stampSettings = "Stamp Settings"
savingCopy = "Preparing download..."
saveFailed = "Unable to save copy"
saveReady = "Download ready"
selectAndMove = "Select and Edit"
editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size."
editStampHint = "To change the image, delete this stamp and add a new one."
editSwitchToSelect = "Switch to Select & Edit to edit this annotation."
undo = "Undo"
redo = "Redo"
applyChanges = "Apply Changes"
[search] [search]
title = "Search PDF" title = "Search PDF"
@@ -4154,20 +4060,12 @@ settings = "Settings"
adminSettings = "Admin Settings" adminSettings = "Admin Settings"
allTools = "Tools" allTools = "Tools"
reader = "Reader" 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] [quickAccess.helpMenu]
toolsTour = "Tools Tour" toolsTour = "Tools Tour"
toolsTourDesc = "Learn what the tools can do" toolsTourDesc = "Learn what the tools can do"
adminTour = "Admin Tour" adminTour = "Admin Tour"
adminTourDesc = "Explore admin settings & features" adminTourDesc = "Explore admin settings & features"
whatsNewTour = "See what's new in V2"
whatsNewTourDesc = "Tour the updated layout"
[admin] [admin]
error = "Error" error = "Error"
@@ -5361,16 +5259,6 @@ finish = "Finish"
startTour = "Start Tour" startTour = "Start Tour"
startTourDescription = "Take a guided tour of Stirling PDF's key features" 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] [onboarding.welcomeModal]
title = "Welcome to Stirling PDF!" 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?" description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
@@ -5391,10 +5279,6 @@ download = "Download →"
showMeAround = "Show me around" showMeAround = "Show me around"
skipTheTour = "Skip the tour" 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] [onboarding.serverLicense]
skip = "Skip for now" skip = "Skip for now"
seePlans = "See Plans →" seePlans = "See Plans →"
@@ -5933,8 +5817,6 @@ notAvailable = "Audit system not available"
notAvailableMessage = "The audit system is not configured or not available." notAvailableMessage = "The audit system is not configured or not available."
disabled = "Audit logging is disabled" disabled = "Audit logging is disabled"
disabledMessage = "Enable audit logging in your application configuration to track system events." 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] [audit.error]
title = "Error loading audit system" title = "Error loading audit system"
File diff suppressed because it is too large Load Diff
-93
View File
@@ -589,26 +589,6 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@@ -736,12 +716,6 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.6" version = "0.1.6"
@@ -934,15 +908,6 @@ dependencies = [
"syn 2.0.108", "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]] [[package]]
name = "document-features" name = "document-features"
version = "0.2.12" version = "0.2.12"
@@ -1661,12 +1626,6 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.0" version = "0.16.0"
@@ -2861,16 +2820,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "ordered-stream" name = "ordered-stream"
version = "0.2.0" version = "0.2.0"
@@ -3702,16 +3651,6 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "rust_decimal" name = "rust_decimal"
version = "1.39.0" version = "1.39.0"
@@ -4317,7 +4256,6 @@ dependencies = [
"sha2", "sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-log", "tauri-plugin-log",
@@ -4677,27 +4615,6 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-fs" name = "tauri-plugin-fs"
version = "2.4.4" version = "2.4.4"
@@ -4818,7 +4735,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.17", "thiserror 2.0.17",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -5038,15 +4954,6 @@ dependencies = [
"time-core", "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]] [[package]]
name = "tiny_http" name = "tiny_http"
version = "0.12.0" version = "0.12.0"
+1 -2
View File
@@ -29,10 +29,9 @@ tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0" tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4" tauri-plugin-fs = "2.4.4"
tauri-plugin-http = "2.4.4" tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] } tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.0" tauri-plugin-store = "2.1.0"
tauri-plugin-opener = "2.0.0" tauri-plugin-opener = "2.0.0"
tauri-plugin-deep-link = "2.4.5"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] } keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.0", features = ["time", "sync"] } tokio = { version = "1.0", features = ["time", "sync"] }
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
+1 -3
View File
@@ -19,8 +19,6 @@
{ {
"identifier": "fs:allow-read-file", "identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }] "allow": [{ "path": "**" }]
}, }
"opener:default",
"shell:allow-open"
] ]
} }
+1 -36
View File
@@ -1,4 +1,4 @@
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; use tauri::{Manager, RunEvent, WindowEvent, Emitter};
mod utils; mod utils;
mod commands; mod commands;
@@ -28,17 +28,6 @@ use commands::{
}; };
use state::connection_state::AppConnectionState; use state::connection_state::AppConnectionState;
use utils::{add_log, get_tauri_logs}; 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -53,7 +42,6 @@ pub fn run() {
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_deep_link::init())
.manage(AppConnectionState::default()) .manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start // This callback runs when a second instance tries to start
@@ -90,29 +78,6 @@ 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 // Start backend immediately, non-blocking
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
+69 -84
View File
@@ -1,89 +1,74 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF", "productName": "Stirling-PDF",
"version": "2.1.3", "version": "2.0.0",
"identifier": "stirling.pdf.dev", "identifier": "stirling.pdf.dev",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop", "beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "npm run build -- --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"
}
}, },
"app": { "windows": {
"windows": [ "certificateThumbprint": null,
{ "digestAlgorithm": "sha256",
"title": "Stirling-PDF", "timestampUrl": "http://timestamp.digicert.com"
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false
}
]
}, },
"bundle": { "macOS": {
"active": true, "minimumSystemVersion": "10.15",
"targets": [ "signingIdentity": null,
"deb", "entitlements": null,
"rpm", "providerShortName": null
"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
}
}
} }
+7 -10
View File
@@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
import { RightRailProvider } from "@app/contexts/RightRailContext"; import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext"; import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext"; import { SignatureProvider } from "@app/contexts/SignatureContext";
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext";
@@ -96,15 +95,13 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<ViewerProvider> <ViewerProvider>
<PageEditorProvider> <PageEditorProvider>
<SignatureProvider> <SignatureProvider>
<AnnotationProvider> <RightRailProvider>
<RightRailProvider> <TourOrchestrationProvider>
<TourOrchestrationProvider> <AdminTourOrchestrationProvider>
<AdminTourOrchestrationProvider> {children}
{children} </AdminTourOrchestrationProvider>
</AdminTourOrchestrationProvider> </TourOrchestrationProvider>
</TourOrchestrationProvider> </RightRailProvider>
</RightRailProvider>
</AnnotationProvider>
</SignatureProvider> </SignatureProvider>
</PageEditorProvider> </PageEditorProvider>
</ViewerProvider> </ViewerProvider>
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core'; import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface ColorPickerProps { interface ColorPickerProps {
@@ -8,10 +8,6 @@ interface ColorPickerProps {
selectedColor: string; selectedColor: string;
onColorChange: (color: string) => void; onColorChange: (color: string) => void;
title?: string; title?: string;
opacity?: number;
onOpacityChange?: (opacity: number) => void;
showOpacity?: boolean;
opacityLabel?: string;
} }
export const ColorPicker: React.FC<ColorPickerProps> = ({ export const ColorPicker: React.FC<ColorPickerProps> = ({
@@ -19,15 +15,10 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
onClose, onClose,
selectedColor, selectedColor,
onColorChange, onColorChange,
title, title
opacity,
onOpacityChange,
showOpacity = false,
opacityLabel,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour'); const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
return ( return (
<Modal <Modal
@@ -47,23 +38,6 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
size="lg" size="lg"
fullWidth fullWidth
/> />
{showOpacity && onOpacityChange && opacity !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
<Slider
min={10}
max={100}
value={opacity}
onChange={onOpacityChange}
marks={[
{ value: 25, label: '25%' },
{ value: 50, label: '50%' },
{ value: 75, label: '75%' },
{ value: 100, label: '100%' },
]}
/>
</Stack>
)}
<Group justify="flex-end"> <Group justify="flex-end">
<Button onClick={onClose}> <Button onClick={onClose}>
{t('common.done', 'Done')} {t('common.done', 'Done')}
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core'; import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
interface TextInputWithFontProps { interface TextInputWithFontProps {
@@ -12,8 +11,6 @@ interface TextInputWithFontProps {
onFontFamilyChange: (family: string) => void; onFontFamilyChange: (family: string) => void;
textColor?: string; textColor?: string;
onTextColorChange?: (color: string) => void; onTextColorChange?: (color: string) => void;
textAlign?: 'left' | 'center' | 'right';
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
disabled?: boolean; disabled?: boolean;
label: string; label: string;
placeholder: string; placeholder: string;
@@ -33,8 +30,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onFontFamilyChange, onFontFamilyChange,
textColor = '#000000', textColor = '#000000',
onTextColorChange, onTextColorChange,
textAlign = 'left',
onTextAlignChange,
disabled = false, disabled = false,
label, label,
placeholder, placeholder,
@@ -44,7 +39,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
colorLabel, colorLabel,
onAnyChange onAnyChange
}) => { }) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox(); const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@@ -218,23 +212,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
}} }}
/> />
)} )}
{/* Text Alignment */}
{onTextAlignChange && (
<SegmentedControl
value={textAlign}
onChange={(value: string) => {
onTextAlignChange(value as 'left' | 'center' | 'right');
onAnyChange?.();
}}
disabled={disabled}
data={[
{ label: t('textAlign.left', 'Left'), value: 'left' },
{ label: t('textAlign.center', 'Center'), value: 'center' },
{ label: t('textAlign.right', 'Right'), value: 'right' },
]}
/>
)}
</Stack> </Stack>
); );
}; };
@@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
) { ) {
return t('onboarding.serverLicense.upgrade', 'Upgrade now →'); return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
} }
// Translate the label (it's a translation key) // Translate the label (it's a translation key)
const label = button.label ?? ''; const label = button.label ?? '';
if (!label) return ''; if (!label) return '';
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next') // Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split('.').pop() || label; const fallback = label.split('.').pop() || label;
return t(label, fallback); return t(label, fallback);
@@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
<Group gap={12}>{rightButtons.map(renderButton)}</Group> <Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group> </Group>
); );
} }
@@ -5,6 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { isAuthRoute } from '@app/constants/routes'; import { isAuthRoute } from '@app/constants/routes';
import { dispatchTourState } from '@app/constants/events'; import { dispatchTourState } from '@app/constants/events';
import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator'; import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding'; import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding';
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour'; import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour';
import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide'; import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide';
@@ -19,12 +20,10 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig'; import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig'; import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig';
import { removeAllGlows } from '@app/components/onboarding/tourGlow'; import { removeAllGlows } from '@app/components/onboarding/tourGlow';
import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useServerExperience } from '@app/hooks/useServerExperience'; import { useServerExperience } from '@app/hooks/useServerExperience';
import { useAppConfig } from '@app/contexts/AppConfigContext'; import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal';
import apiClient from '@app/services/apiClient';
import '@app/components/onboarding/OnboardingTour.css'; import '@app/components/onboarding/OnboardingTour.css';
export default function Onboarding() { export default function Onboarding() {
@@ -40,11 +39,6 @@ export default function Onboarding() {
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload(); const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest(); const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest(); 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) => { const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => {
actions.updateRuntimeState({ selectedRole: role }); actions.updateRuntimeState({ selectedRole: role });
@@ -56,34 +50,7 @@ export default function Onboarding() {
window.location.href = '/login'; window.location.href = '/login';
}, [actions]); }, [actions]);
// Check if we should show analytics modal before onboarding const handleButtonAction = useCallback((action: ButtonAction) => {
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) { switch (action) {
case 'next': case 'next':
case 'complete-close': case 'complete-close':
@@ -102,43 +69,35 @@ export default function Onboarding() {
case 'security-next': case 'security-next':
if (!runtimeState.selectedRole) return; if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== 'admin') { if (runtimeState.selectedRole !== 'admin') {
actions.updateRuntimeState({ tourType: 'whatsnew' }); actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
setIsTourOpen(true);
} }
actions.complete(); actions.complete();
break; break;
case 'launch-admin': case 'launch-admin':
actions.updateRuntimeState({ tourType: 'admin' }); actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' });
setIsTourOpen(true); actions.complete();
break; break;
case 'launch-tools': case 'launch-tools':
actions.updateRuntimeState({ tourType: 'whatsnew' }); actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' });
setIsTourOpen(true); actions.complete();
break; break;
case 'launch-auto': { case 'launch-auto': {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew'; const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools';
actions.updateRuntimeState({ tourType }); actions.updateRuntimeState({ tourRequested: true, tourType });
setIsTourOpen(true); actions.complete();
break; break;
} }
case 'skip-to-license': case 'skip-to-license':
actions.complete(); markStepSeen('tour');
break; actions.updateRuntimeState({ tourRequested: false });
case 'skip-tour':
actions.complete(); actions.complete();
break; break;
case 'see-plans': case 'see-plans':
actions.complete(); actions.complete();
navigate('/settings/adminPlan'); navigate('/settings/adminPlan');
break; break;
case 'enable-analytics':
await handleAnalyticsChoice(true);
break;
case 'disable-analytics':
await handleAnalyticsChoice(false);
break;
} }
}, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]); }, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]);
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
const [isTourOpen, setIsTourOpen] = useState(false); const [isTourOpen, setIsTourOpen] = useState(false);
@@ -158,29 +117,14 @@ export default function Onboarding() {
backToAllTools: tourOrch.backToAllTools, backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool, selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile, loadSampleFile: tourOrch.loadSampleFile,
switchToActiveFiles: tourOrch.switchToActiveFiles,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal]
);
const whatsNewStepsConfig = useMemo(
() => createWhatsNewStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
openFilesModal,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer, switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor, switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles, switchToActiveFiles: tourOrch.switchToActiveFiles,
selectFirstFile: tourOrch.selectFirstFile, selectFirstFile: tourOrch.selectFirstFile,
pinFile: tourOrch.pinFile,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
}, },
}), }),
[t, tourOrch, closeFilesModal, openFilesModal] [t, tourOrch, closeFilesModal, openFilesModal]
@@ -200,19 +144,21 @@ export default function Onboarding() {
); );
const tourSteps = useMemo<StepType[]>(() => { const tourSteps = useMemo<StepType[]>(() => {
switch (runtimeState.tourType) { const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
case 'admin': return Object.values(config);
return Object.values(adminStepsConfig); }, [adminStepsConfig, runtimeState.tourType, userStepsConfig]);
case 'whatsnew':
return Object.values(whatsNewStepsConfig); useEffect(() => {
default: if (currentStep?.id === 'tour' && !isTourOpen) {
return Object.values(userStepsConfig); markStepSeen('tour');
setIsTourOpen(true);
} }
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]); }, [currentStep, isTourOpen, activeFlow]);
useEffect(() => { useEffect(() => {
if (externalTourRequested) { if (externalTourRequested) {
actions.updateRuntimeState({ tourType: requestedTourType }); actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType });
markStepSeen('tour');
setIsTourOpen(true); setIsTourOpen(true);
clearTourRequest(); clearTourRequest();
} }
@@ -230,9 +176,9 @@ export default function Onboarding() {
} else { } else {
tourOrch.restoreWorkbenchState(); tourOrch.restoreWorkbenchState();
} }
// Advance to next onboarding step after tour completes markStepSeen('tour');
actions.complete(); if (currentStep?.id === 'tour') actions.complete();
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]); }, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback((args: AdvanceArgs) => { const handleAdvanceTour = useCallback((args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
@@ -270,10 +216,8 @@ export default function Onboarding() {
firstLoginUsername: runtimeState.firstLoginUsername, firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged, onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials, usingDefaultCredentials: runtimeState.usingDefaultCredentials,
analyticsError,
analyticsLoading,
}); });
}, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]); }, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]);
const modalSlideCount = useMemo(() => { const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === 'modal-slide').length; return activeFlow.filter((step) => step.type === 'modal-slide').length;
@@ -293,45 +237,8 @@ export default function Onboarding() {
return null; 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) { if (showLicenseSlide) {
const baseSlideDefinition = SLIDE_DEFINITIONS['server-license']; const slideDefinition = 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 effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({ const slideContent = slideDefinition.createSlide({
osLabel: '', osLabel: '',
@@ -343,7 +250,7 @@ export default function Onboarding() {
licenseNotice: effectiveLicenseNotice, licenseNotice: effectiveLicenseNotice,
loginEnabled: serverExperience.loginEnabled, loginEnabled: serverExperience.loginEnabled,
}); });
return ( return (
<OnboardingModalSlide <OnboardingModalSlide
slideDefinition={slideDefinition} slideDefinition={slideDefinition}
@@ -364,34 +271,40 @@ 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) { if (isLoading || !isActive || !currentStep) {
return tourComponent; return (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
} }
// 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) { switch (currentStep.type) {
case 'tool-prompt': case 'tool-prompt':
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />; 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': case 'modal-slide':
if (!currentSlideDefinition || !currentSlideContent) return null; if (!currentSlideDefinition || !currentSlideContent) return null;
return ( return (
@@ -6,9 +6,8 @@
*/ */
import React from 'react'; import React from 'react';
import { Modal, Stack, ActionIcon } from '@mantine/core'; import { Modal, Stack } from '@mantine/core';
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined'; 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 { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig'; import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig';
@@ -29,7 +28,6 @@ interface OnboardingModalSlideProps {
currentModalSlideIndex: number; currentModalSlideIndex: number;
onSkip: () => void; onSkip: () => void;
onAction: (action: ButtonAction) => void; onAction: (action: ButtonAction) => void;
allowDismiss?: boolean;
} }
export default function OnboardingModalSlide({ export default function OnboardingModalSlide({
@@ -40,7 +38,6 @@ export default function OnboardingModalSlide({
currentModalSlideIndex, currentModalSlideIndex,
onSkip, onSkip,
onAction, onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) { }: OnboardingModalSlideProps) {
const renderHero = () => { const renderHero = () => {
@@ -65,9 +62,6 @@ export default function OnboardingModalSlide({
{slideDefinition.hero.type === 'lock' && ( {slideDefinition.hero.type === 'lock' && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} /> <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 === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
{slideDefinition.hero.type === 'logo' && ( {slideDefinition.hero.type === 'logo' && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" /> <img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
@@ -81,7 +75,6 @@ export default function OnboardingModalSlide({
opened={true} opened={true}
onClose={onSkip} onClose={onSkip}
closeOnClickOutside={false} closeOnClickOutside={false}
closeOnEscape={allowDismiss}
centered centered
size="lg" size="lg"
radius="lg" radius="lg"
@@ -100,31 +93,6 @@ export default function OnboardingModalSlide({
isActive isActive
slideKey={slideContent.key} 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}`}> <div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()} {renderHero()}
</div> </div>
@@ -146,9 +114,7 @@ export default function OnboardingModalSlide({
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style> <style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div> </div>
{modalSlideCount > 1 && ( <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
<OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />
)}
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<SlideButtons <SlideButtons
@@ -49,7 +49,7 @@ interface CloseArgs {
interface OnboardingTourProps { interface OnboardingTourProps {
tourSteps: StepType[]; tourSteps: StepType[];
tourType: 'admin' | 'tools' | 'whatsnew'; tourType: 'admin' | 'tools';
isRTL: boolean; isRTL: boolean;
t: TFunction; t: TFunction;
isOpen: boolean; isOpen: boolean;
@@ -4,8 +4,6 @@ import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckS
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide'; 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'; import { SlideConfig, LicenseNotice } from '@app/types/types';
export type SlideId = export type SlideId =
@@ -14,11 +12,9 @@ export type SlideId =
| 'desktop-install' | 'desktop-install'
| 'security-check' | 'security-check'
| 'admin-overview' | 'admin-overview'
| 'server-license' | 'server-license';
| 'tour-overview'
| 'analytics-choice';
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics'; export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock';
export type ButtonAction = export type ButtonAction =
| 'next' | 'next'
@@ -31,10 +27,7 @@ export type ButtonAction =
| 'launch-tools' | 'launch-tools'
| 'launch-auto' | 'launch-auto'
| 'see-plans' | 'see-plans'
| 'skip-to-license' | 'skip-to-license';
| 'skip-tour'
| 'enable-analytics'
| 'disable-analytics';
export interface FlowState { export interface FlowState {
selectedRole: 'admin' | 'user' | null; selectedRole: 'admin' | 'user' | null;
@@ -59,8 +52,6 @@ export interface SlideFactoryParams {
firstLoginUsername?: string; firstLoginUsername?: string;
onPasswordChanged?: () => void; onPasswordChanged?: () => void;
usingDefaultCredentials?: boolean; usingDefaultCredentials?: boolean;
analyticsError?: string | null;
analyticsLoading?: boolean;
} }
export interface HeroDefinition { export interface HeroDefinition {
@@ -88,9 +79,9 @@ export interface SlideDefinition {
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = { export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
'first-login': { 'first-login': {
id: 'first-login', id: 'first-login',
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
FirstLoginSlide({ FirstLoginSlide({
username: firstLoginUsername || '', username: firstLoginUsername || '',
onPasswordChanged: onPasswordChanged || (() => {}), onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false, usingDefaultCredentials: usingDefaultCredentials || false,
}), }),
@@ -203,13 +194,6 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: 'dual-icon' }, hero: { type: 'dual-icon' },
buttons: [ buttons: [
{
key: 'license-back',
type: 'icon',
icon: 'chevron-left',
group: 'left',
action: 'prev',
},
{ {
key: 'license-close', key: 'license-close',
type: 'button', type: 'button',
@@ -228,58 +212,5 @@ 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,20 +5,21 @@ export type OnboardingStepId =
| 'security-check' | 'security-check'
| 'admin-overview' | 'admin-overview'
| 'tool-layout' | 'tool-layout'
| 'tour-overview' | 'tour'
| 'server-license' | 'server-license'
| 'analytics-choice'; | 'analytics-choice';
export type OnboardingStepType = export type OnboardingStepType =
| 'modal-slide' | 'modal-slide'
| 'tool-prompt'; | 'tool-prompt'
| 'tour'
| 'analytics-modal';
export interface OnboardingRuntimeState { export interface OnboardingRuntimeState {
selectedRole: 'admin' | 'user' | null; selectedRole: 'admin' | 'user' | null;
tourRequested: boolean; tourRequested: boolean;
tourType: 'admin' | 'tools' | 'whatsnew'; tourType: 'admin' | 'tools';
isDesktopApp: boolean; isDesktopApp: boolean;
desktopSlideEnabled: boolean;
analyticsNotConfigured: boolean; analyticsNotConfigured: boolean;
analyticsEnabled: boolean; analyticsEnabled: boolean;
licenseNotice: { licenseNotice: {
@@ -41,13 +42,13 @@ export interface OnboardingStep {
id: OnboardingStepId; id: OnboardingStepId;
type: OnboardingStepType; type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean; condition: (ctx: OnboardingConditionContext) => boolean;
slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice'; slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license';
} }
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null, selectedRole: null,
tourRequested: false, tourRequested: false,
tourType: 'whatsnew', tourType: 'tools',
isDesktopApp: false, isDesktopApp: false,
analyticsNotConfigured: false, analyticsNotConfigured: false,
analyticsEnabled: false, analyticsEnabled: false,
@@ -60,7 +61,6 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
requiresPasswordChange: false, requiresPasswordChange: false,
firstLoginUsername: '', firstLoginUsername: '',
usingDefaultCredentials: false, usingDefaultCredentials: false,
desktopSlideEnabled: true,
}; };
export const ONBOARDING_STEPS: OnboardingStep[] = [ export const ONBOARDING_STEPS: OnboardingStep[] = [
@@ -76,34 +76,33 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'welcome', slideId: 'welcome',
condition: () => true, 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', id: 'admin-overview',
type: 'modal-slide', type: 'modal-slide',
slideId: 'admin-overview', slideId: 'admin-overview',
condition: (ctx) => ctx.effectiveIsAdmin, condition: (ctx) => ctx.effectiveIsAdmin,
}, },
{
id: 'desktop-install',
type: 'modal-slide',
slideId: 'desktop-install',
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
},
{
id: 'security-check',
type: 'modal-slide',
slideId: 'security-check',
condition: () => false,
},
{ {
id: 'tool-layout', id: 'tool-layout',
type: 'tool-prompt', type: 'tool-prompt',
condition: () => false, condition: () => true,
}, },
{ {
id: 'tour-overview', id: 'tour',
type: 'modal-slide', type: 'tour',
slideId: 'tour-overview', condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin,
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin',
}, },
{ {
id: 'server-license', id: 'server-license',
@@ -111,6 +110,11 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
slideId: 'server-license', slideId: 'server-license',
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, 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 { export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
@@ -1,71 +1,94 @@
import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
const STORAGE_PREFIX = 'onboarding'; const STORAGE_PREFIX = 'onboarding';
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
export function isOnboardingCompleted(): boolean { export function getStorageKey(stepId: OnboardingStepId): string {
return `${STORAGE_PREFIX}::${stepId}`;
}
export function hasSeenStep(stepId: OnboardingStepId): boolean {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
try { try {
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true'; return localStorage.getItem(getStorageKey(stepId)) === 'true';
} catch { } catch {
return false; return false;
} }
} }
export function markOnboardingCompleted(): void { export function markStepSeen(stepId: OnboardingStepId): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true'); localStorage.setItem(getStorageKey(stepId), 'true');
} catch (error) { } catch (error) {
console.error('[onboardingStorage] Error marking onboarding as completed:', error); console.error('[onboardingStorage] Error marking step as seen:', error);
} }
} }
export function resetOnboardingProgress(): void { export function resetStepSeen(stepId: OnboardingStepId): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
localStorage.removeItem(ONBOARDING_COMPLETED_KEY); localStorage.removeItem(getStorageKey(stepId));
} catch (error) { } catch (error) {
console.error('[onboardingStorage] Error resetting onboarding progress:', error); console.error('[onboardingStorage] Error resetting step seen:', error);
} }
} }
export function hasShownToursTooltip(): boolean { export function resetAllOnboardingProgress(): void {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return;
try { try {
return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true'; const prefix = `${STORAGE_PREFIX}::`;
} catch { const keysToRemove: string[] = [];
return false; 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);
} }
} }
export function markToursTooltipShown(): void { export function getOnboardingStorageState(): Record<string, boolean> {
if (typeof window === 'undefined') return; const state: Record<string, boolean> = {};
try { ONBOARDING_STEPS.forEach((step) => {
localStorage.setItem(TOURS_TOOLTIP_KEY, 'true'); state[step.id] = hasSeenStep(step.id);
} catch (error) { });
console.error('[onboardingStorage] Error marking tours tooltip as shown:', error); return state;
}
} }
export function migrateFromLegacyPreferences(): void { export function migrateFromLegacyPreferences(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const migrationKey = `${STORAGE_PREFIX}::migrated`; const migrationKey = `${STORAGE_PREFIX}::migrated`;
try { try {
// Skip if already migrated // Skip if already migrated
if (localStorage.getItem(migrationKey) === 'true') return; if (localStorage.getItem(migrationKey) === 'true') return;
const prefsRaw = localStorage.getItem('stirlingpdf_preferences'); const prefsRaw = localStorage.getItem('stirlingpdf_preferences');
if (prefsRaw) { if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>; const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// If user had completed onboarding in old system, mark new system as complete // Migrate based on legacy flags
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) { if (prefs.hasSeenIntroOnboarding === true) {
markOnboardingCompleted(); markStepSeen('welcome');
markStepSeen('desktop-install');
markStepSeen('security-check');
markStepSeen('admin-overview');
} }
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 // Mark migration complete
localStorage.setItem(migrationKey, 'true'); localStorage.setItem(migrationKey, 'true');
} catch { } catch {
@@ -12,8 +12,8 @@ import {
DEFAULT_RUNTIME_STATE, DEFAULT_RUNTIME_STATE,
} from '@app/components/onboarding/orchestrator/onboardingConfig'; } from '@app/components/onboarding/orchestrator/onboardingConfig';
import { import {
isOnboardingCompleted, hasSeenStep,
markOnboardingCompleted, markStepSeen,
migrateFromLegacyPreferences, migrateFromLegacyPreferences,
} from '@app/components/onboarding/orchestrator/onboardingStorage'; } from '@app/components/onboarding/orchestrator/onboardingStorage';
import { accountService } from '@app/services/accountService'; import { accountService } from '@app/services/accountService';
@@ -35,15 +35,12 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return baseState; return baseState;
} }
try { try {
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true'; const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true';
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools';
const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew')
? sessionTourType
: 'whatsnew';
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null; const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null;
return { return {
...baseState, ...baseState,
tourRequested, tourRequested,
@@ -57,7 +54,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void { function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
if (state.tourRequested !== undefined) { if (state.tourRequested !== undefined) {
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false'); sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false');
@@ -79,7 +76,7 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
function clearRuntimeStateSession(): void { function clearRuntimeStateSession(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED); sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
sessionStorage.removeItem(SESSION_TOUR_TYPE); sessionStorage.removeItem(SESSION_TOUR_TYPE);
@@ -148,7 +145,7 @@ export function useOnboardingOrchestrator(
const location = useLocation(); const location = useLocation();
const bypassOnboarding = useBypassOnboarding(); const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState) getInitialRuntimeState(defaultState)
); );
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
@@ -169,7 +166,6 @@ export function useOnboardingOrchestrator(
...prev, ...prev,
analyticsEnabled: config?.enableAnalytics === true, analyticsEnabled: config?.enableAnalytics === true,
analyticsNotConfigured: config?.enableAnalytics == null, analyticsNotConfigured: config?.enableAnalytics == null,
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
licenseNotice: { licenseNotice: {
totalUsers: serverExperience.totalUsers, totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit, freeTierLimit: serverExperience.freeTierLimit,
@@ -225,7 +221,7 @@ export function useOnboardingOrchestrator(
const conditionContext = useMemo<OnboardingConditionContext>(() => ({ const conditionContext = useMemo<OnboardingConditionContext>(() => ({
...serverExperience, ...serverExperience,
...runtimeState, ...runtimeState,
effectiveIsAdmin: serverExperience.effectiveIsAdmin || effectiveIsAdmin: serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'), (!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'),
}), [serverExperience, runtimeState]); }), [serverExperience, runtimeState]);
@@ -239,44 +235,53 @@ export function useOnboardingOrchestrator(
// Wait for config AND admin status before calculating initial step // Wait for config AND admin status before calculating initial step
const adminStatusResolved = !configLoading && ( const adminStatusResolved = !configLoading && (
config?.enableLogin === false || config?.enableLogin === false ||
config?.enableLogin === undefined || config?.enableLogin === undefined ||
config?.isAdmin !== undefined config?.isAdmin !== undefined
); );
useEffect(() => { useEffect(() => {
if (configLoading || !adminStatusResolved) return; if (configLoading || !adminStatusResolved || activeFlow.length === 0) return;
// If there are no steps to show, mark initialized/completed baseline let firstUnseenIndex = -1;
if (activeFlow.length === 0) { for (let i = 0; i < activeFlow.length; i++) {
setCurrentStepIndex(0); // Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true
initialIndexSet.current = true; const isFirstLoginStep = activeFlow[i].id === 'first-login';
return; const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id);
if (shouldTreatAsUnseen) {
firstUnseenIndex = i;
break;
}
} }
// If onboarding has been completed, don't show it // Force reset index when password change is required (overrides initialIndexSet)
if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) { if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
} else if (firstUnseenIndex === -1) {
setCurrentStepIndex(activeFlow.length); setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true; initialIndexSet.current = true;
return; } else if (!initialIndexSet.current) {
} setCurrentStepIndex(firstUnseenIndex);
// Start from the beginning
if (!initialIndexSet.current) {
setCurrentStepIndex(0);
initialIndexSet.current = true; initialIndexSet.current = true;
} }
}, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]); }, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]);
const totalSteps = activeFlow.length; const totalSteps = activeFlow.length;
const isComplete = isInitialized && const allStepsAlreadySeen = useMemo(() => {
(totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted()); if (activeFlow.length === 0) return false;
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps) return activeFlow.every(step => hasSeenStep(step.id));
? activeFlow[currentStepIndex] }, [activeFlow]);
const isComplete = isInitialized && initialIndexSet.current &&
(currentStepIndex >= totalSteps || allStepsAlreadySeen);
const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen)
? activeFlow[currentStepIndex]
: null; : null;
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== 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); !initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => { useEffect(() => {
@@ -288,33 +293,35 @@ export function useOnboardingOrchestrator(
}, [isComplete]); }, [isComplete]);
const next = useCallback(() => { const next = useCallback(() => {
const nextIndex = currentStepIndex + 1; if (currentStep) markStepSeen(currentStep.id);
if (nextIndex >= totalSteps) { setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
// Reached the end, mark onboarding as completed }, [currentStep, totalSteps]);
markOnboardingCompleted();
}
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const prev = useCallback(() => { const prev = useCallback(() => {
setCurrentStepIndex((prev) => Math.max(prev - 1, 0)); setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
}, []); }, []);
const skip = useCallback(() => { const skip = useCallback(() => {
// Skip marks the entire onboarding as completed if (currentStep) markStepSeen(currentStep.id);
markOnboardingCompleted(); setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
setCurrentStepIndex(totalSteps); }, [currentStep, totalSteps]);
}, [totalSteps]);
const complete = useCallback(() => { const complete = useCallback(() => {
const nextIndex = currentStepIndex + 1; if (currentStep) markStepSeen(currentStep.id);
if (nextIndex >= totalSteps) { setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps));
// Reached the end, mark onboarding as completed }, [currentStep, totalSteps]);
markOnboardingCompleted();
}
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
useEffect(() => {
if (!currentStep || isLoading) {
return;
}
// Special case: never auto-complete first-login step if requiresPasswordChange is true
const isFirstLoginStep = currentStep.id === 'first-login';
if (!isFirstLoginStep && hasSeenStep(currentStep.id)) {
complete();
}
}, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => { const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates); persistRuntimeState(updates);
@@ -1,55 +0,0 @@
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,
},
};
}
@@ -1,27 +0,0 @@
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,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage'; import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig';
import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage';
const SESSION_KEY = 'onboarding::bypass-all'; const SESSION_KEY = 'onboarding::bypass-all';
const PARAM_KEY = 'bypassOnboarding'; const PARAM_KEY = 'bypassOnboarding';
@@ -33,12 +34,13 @@ function setStoredBypass(enabled: boolean): void {
/** /**
* Detects the `bypassOnboarding` query parameter and stores it in session storage * Detects the `bypassOnboarding` query parameter and stores it in session storage
* so that onboarding remains disabled while the app is open. Also marks onboarding * so that onboarding remains disabled while the app is open. Also marks all steps
* as completed to ensure any dependent UI elements remain hidden. * as seen to ensure any dependent UI elements remain hidden.
*/ */
export function useBypassOnboarding(): boolean { export function useBypassOnboarding(): boolean {
const location = useLocation(); const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass()); const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
const stepsMarkedRef = useRef(false);
const shouldBypassFromSearch = useMemo(() => { const shouldBypassFromSearch = useMemo(() => {
try { try {
@@ -55,9 +57,14 @@ export function useBypassOnboarding(): boolean {
setBypassOnboarding(nextBypass); setBypassOnboarding(nextBypass);
if (nextBypass) { if (nextBypass) {
setStoredBypass(true); setStoredBypass(true);
markOnboardingCompleted();
} }
}, [shouldBypassFromSearch]); }, [shouldBypassFromSearch]);
useEffect(() => {
if (!bypassOnboarding || stepsMarkedRef.current) return;
stepsMarkedRef.current = true;
ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id));
}, [bypassOnboarding]);
return bypassOnboarding; return bypassOnboarding;
} }
@@ -51,14 +51,14 @@ export function useTourRequest(): {
clearTourRequest: () => void; clearTourRequest: () => void;
} { } {
const [tourRequested, setTourRequested] = useState(false); const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>('whatsnew'); const [requestedTourType, setRequestedTourType] = useState<TourType>('tools');
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const handleTourRequest = (event: Event) => { const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>; const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? 'whatsnew'); setRequestedTourType(detail?.tourType ?? 'tools');
setTourRequested(true); setTourRequested(true);
}; };
@@ -8,8 +8,12 @@ export enum TourStep {
FILES_BUTTON, FILES_BUTTON,
FILE_SOURCES, FILE_SOURCES,
WORKBENCH, WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES, ACTIVE_FILES,
FILE_CHECKBOX, FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS, CROP_SETTINGS,
RUN_BUTTON, RUN_BUTTON,
RESULTS, RESULTS,
@@ -24,7 +28,10 @@ interface UserStepActions {
backToAllTools: () => void; backToAllTools: () => void;
selectCropTool: () => void; selectCropTool: () => void;
loadSampleFile: () => void; loadSampleFile: () => void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void; switchToActiveFiles: () => void;
selectFirstFile: () => void;
pinFile: () => void; pinFile: () => void;
modifyCropSettings: () => void; modifyCropSettings: () => void;
executeTool: () => void; executeTool: () => void;
@@ -43,7 +50,10 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
backToAllTools, backToAllTools,
selectCropTool, selectCropTool,
loadSampleFile, loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles, switchToActiveFiles,
selectFirstFile,
pinFile, pinFile,
modifyCropSettings, modifyCropSettings,
executeTool, executeTool,
@@ -98,6 +108,26 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
position: 'center', position: 'center',
padding: 0, 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]: { [TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]', 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."), 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."),
@@ -111,6 +141,14 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs)
position: 'top', position: 'top',
padding: 10, 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]: { [TourStep.CROP_SETTINGS]: {
selector: '[data-tour="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."), 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."),
@@ -1,197 +0,0 @@
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,
},
};
}
@@ -0,0 +1,139 @@
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,5 +1,6 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded'; import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
@@ -10,11 +11,13 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess
interface AllToolsNavButtonProps { interface AllToolsNavButtonProps {
activeButton: string; activeButton: string;
setActiveButton: (id: string) => void; setActiveButton: (id: string) => void;
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
} }
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
activeButton, activeButton,
setActiveButton, setActiveButton,
tooltipPosition = 'right'
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
@@ -52,18 +55,26 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
}; };
return ( return (
<div className="mt-4 mb-2"> <Tooltip
<QuickAccessButton content={t("quickAccess.allTools", "Tools")}
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />} position={tooltipPosition}
label={t("quickAccess.allTools", "Tools")} arrow
isActive={isActive} containerStyle={{ marginTop: "-1rem" }}
onClick={handleNavClick} maxWidth={200}
href={navProps.href} >
ariaLabel={t("quickAccess.allTools", "Tools")} <div className="mt-4 mb-2">
textClassName="all-tools-text" <QuickAccessButton
component="a" icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
/> label={t("quickAccess.allTools", "Tools")}
</div> isActive={isActive}
onClick={handleNavClick}
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
</Tooltip>
); );
}; };
@@ -95,22 +95,6 @@ export default function Footer({
> >
{t('legal.terms', 'Terms and Conditions')} {t('legal.terms', 'Terms and Conditions')}
</a> </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 <a
className="footer-link px-3" className="footer-link px-3"
target="_blank" target="_blank"
@@ -11,7 +11,6 @@ import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation';
import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers';
import { ButtonConfig } from '@app/types/sidebar'; import { ButtonConfig } from '@app/types/sidebar';
import '@app/components/shared/quickAccessBar/QuickAccessBar.css'; import '@app/components/shared/quickAccessBar/QuickAccessBar.css';
import { Tooltip } from '@app/components/shared/Tooltip';
import AllToolsNavButton from '@app/components/shared/AllToolsNavButton'; import AllToolsNavButton from '@app/components/shared/AllToolsNavButton';
import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton"; import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton";
import AppConfigModal from '@app/components/shared/AppConfigModal'; import AppConfigModal from '@app/components/shared/AppConfigModal';
@@ -19,7 +18,6 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
import { requestStartTour } from '@app/constants/events'; import { requestStartTour } from '@app/constants/events';
import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton'; import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton';
import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip';
import { import {
isNavButtonActive, isNavButtonActive,
@@ -43,14 +41,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const [configModalOpen, setConfigModalOpen] = useState(false); const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools'); const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
const {
tooltipOpen,
manualCloseOnly,
showCloseButton,
toursMenuOpen,
setToursMenuOpen,
handleTooltipOpenChange,
} = useToursTooltip();
const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl';
@@ -176,8 +166,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const bottomButtons: ButtonConfig[] = [ const bottomButtons: ButtonConfig[] = [
{ {
id: 'help', id: 'help',
name: t("quickAccess.tours", "Tours"), name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="explore-rounded" width="1.25rem" height="1.25rem" />, icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
isRound: true, isRound: true,
size: 'md', size: 'md',
type: 'action', type: 'action',
@@ -202,7 +192,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div <div
ref={ref} ref={ref}
data-sidebar="quick-access" data-sidebar="quick-access"
data-tour="quick-access-bar"
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`} className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
> >
{/* Fixed header outside scrollable area */} {/* Fixed header outside scrollable area */}
@@ -258,83 +247,58 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// Handle help button with menu or direct action // Handle help button with menu or direct action
if (buttonConfig.id === 'help') { if (buttonConfig.id === 'help') {
const isAdmin = config?.isAdmin === true; 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'),
}] : []),
];
const helpButtonNode = ( // If not admin, just show button that starts tools tour directly
<div data-tour="help-button"> if (!isAdmin) {
<Menu return (
position={isRTL ? 'left' : 'right'} <div
offset={10} key={buttonConfig.id}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} data-tour="help-button"
opened={toursMenuOpen} onClick={() => requestStartTour('tools')}
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> <Menu.Target>
<div>{renderNavButton(buttonConfig, index)}</div> <div>{renderNavButton(buttonConfig, index)}</div>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{tourItems.map((item) => ( <Menu.Item
<Menu.Item leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
key={item.key} onClick={() => requestStartTour('tools')}
leftSection={item.icon} >
onClick={item.onClick} <div>
> <div style={{ fontWeight: 500 }}>
<div> {t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
<div style={{ fontWeight: 500 }}>
{item.title}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{item.description}
</div>
</div> </div>
</Menu.Item> <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.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</div> </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); const buttonNode = renderNavButton(buttonConfig, index);
+11 -20
View File
@@ -33,10 +33,6 @@ export interface TooltipProps {
disabled?: boolean; disabled?: boolean;
/** If false, tooltip will not open on focus (hover only) */ /** If false, tooltip will not open on focus (hover only) */
openOnFocus?: boolean; 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> = ({ export const Tooltip: React.FC<TooltipProps> = ({
@@ -59,8 +55,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
closeOnOutside = true, closeOnOutside = true,
disabled = false, disabled = false,
openOnFocus = true, openOnFocus = true,
manualCloseOnly = false,
showCloseButton = false,
}) => { }) => {
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
@@ -87,7 +81,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
const isControlled = controlledOpen !== undefined; const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const allowAutoClose = !manualCloseOnly;
const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => { const resolvedPosition: NonNullable<TooltipProps['position']> = useMemo(() => {
const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr'; const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr';
@@ -139,11 +132,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
} }
// Not pinned and configured to close on outside // Not pinned and configured to close on outside
if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) { if (closeOnOutside && !insideTooltip && !insideTrigger) {
setOpen(false); setOpen(false);
} }
}, },
[isPinned, closeOnOutside, setOpen, allowAutoClose] [isPinned, closeOnOutside, setOpen]
); );
useEffect(() => { useEffect(() => {
@@ -207,10 +200,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
} }
clearTimers(); clearTimers();
if (allowAutoClose && !isPinned) setOpen(false); if (!isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e); (children.props as any)?.onPointerLeave?.(e);
}, },
[clearTimers, isPinned, setOpen, children.props, allowAutoClose] [clearTimers, isPinned, setOpen, children.props]
); );
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
@@ -264,16 +257,15 @@ export const Tooltip: React.FC<TooltipProps> = ({
return; return;
} }
clearTimers(); clearTimers();
if (allowAutoClose && !isPinned) setOpen(false); if (!isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e); (children.props as any)?.onBlur?.(e);
}, },
[isPinned, setOpen, children.props, allowAutoClose, clearTimers] [isPinned, setOpen, children.props, clearTimers]
); );
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (manualCloseOnly) return;
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') setOpen(false);
}, [setOpen, manualCloseOnly]); }, [setOpen]);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned) // Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => { const handleTooltipPointerEnter = useCallback(() => {
@@ -284,9 +276,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: React.PointerEvent) => { (e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null; const related = e.relatedTarget as Node | null;
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (allowAutoClose && !isPinned) setOpen(false); if (!isPinned) setOpen(false);
}, },
[isPinned, setOpen, allowAutoClose] [isPinned, setOpen]
); );
// Enhance child with handlers and ref // Enhance child with handlers and ref
@@ -309,7 +301,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
}); });
const shouldShowTooltip = open; const shouldShowTooltip = open;
const shouldShowCloseButton = showCloseButton || isPinned;
const tooltipElement = shouldShowTooltip ? ( const tooltipElement = shouldShowTooltip ? (
<div <div
@@ -334,7 +325,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`} className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined} onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
> >
{shouldShowCloseButton && ( {isPinned && (
<button <button
className={styles['tooltip-pin-button']} className={styles['tooltip-pin-button']}
onClick={(e) => { onClick={(e) => {
@@ -372,7 +363,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
<span className={styles['tooltip-title']}>{header.title}</span> <span className={styles['tooltip-title']}>{header.title}</span>
</div> </div>
)} )}
<TooltipContent content={content} tips={tips} extraRightPadding={shouldShowCloseButton ? 48 : 0} /> <TooltipContent content={content} tips={tips} />
</div> </div>
) : null; ) : null;
@@ -1,81 +0,0 @@
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,9 +1,15 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { ActionIcon } from '@mantine/core'; import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon'; import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip'; import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext'; 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 { useNavigationState } from '@app/contexts/NavigationContext';
import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
@@ -17,19 +23,31 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
const { t } = useTranslation(); const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext(); const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); 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 // Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext); 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 // Check if we're in sign mode
const { selectedTool } = useNavigationState(); const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign'; const isSignMode = selectedTool === 'sign';
// Check if we're in any annotation tool that should disable the toggle // Turn off annotation mode when switching away from viewer
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText'; useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
// Check if we're on annotate tool to highlight the button viewerContext.setAnnotationMode(false);
const isAnnotateActive = selectedTool === 'annotate'; }
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode // Don't show any annotation controls in sign mode
if (isSignMode) { if (isSignMode) {
@@ -41,14 +59,13 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
{/* Annotation Visibility Toggle */} {/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}> <Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon <ActionIcon
variant={isAnnotateActive ? "filled" : "subtle"} variant="subtle"
color="blue"
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => { onClick={() => {
viewerContext?.toggleAnnotationsVisibility(); viewerContext?.toggleAnnotationsVisibility();
}} }}
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool} disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
> >
<LocalIcon <LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"} icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -57,6 +74,164 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
/> />
</ActionIcon> </ActionIcon>
</Tooltip> </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); background: var(--bg-raised);
padding: 0.25rem; padding: 0.25rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 0.0625rem solid var(--border-default); border: 0.0625rem solid var(--primary-color, #3b82f6);
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease; transition: background-color 0.2s ease, border-color 0.2s ease;
z-index: 1; z-index: 1;
@@ -60,13 +60,6 @@
border-color: #ef4444 !important; 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 */
.tooltip-header { .tooltip-header {
display: flex; display: flex;
@@ -98,7 +91,7 @@
/* Tooltip Body */ /* Tooltip Body */
.tooltip-body { .tooltip-body {
padding: 1rem; padding: 1rem !important;
color: var(--text-primary) !important; color: var(--text-primary) !important;
font-size: 0.875rem !important; font-size: 0.875rem !important;
line-height: 1.6 !important; line-height: 1.6 !important;
@@ -5,20 +5,18 @@ import { TooltipTip } from '@app/types/tips';
interface TooltipContentProps { interface TooltipContentProps {
content?: React.ReactNode; content?: React.ReactNode;
tips?: TooltipTip[]; tips?: TooltipTip[];
extraRightPadding?: number;
} }
export const TooltipContent: React.FC<TooltipContentProps> = ({ export const TooltipContent: React.FC<TooltipContentProps> = ({
content, content,
tips, tips,
extraRightPadding = 0,
}) => { }) => {
return ( return (
<div <div
className={`${styles['tooltip-body']}`} className={`${styles['tooltip-body']}`}
style={{ style={{
color: 'var(--text-primary)', color: 'var(--text-primary)',
padding: `16px ${16 + extraRightPadding}px 16px 16px`, padding: '16px',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.6' lineHeight: '1.6'
}} }}
@@ -1,9 +1,8 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core"; import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters"; import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector"; import ButtonSelector from "@app/components/shared/ButtonSelector";
import apiClient from "@app/services/apiClient";
interface CompressSettingsProps { interface CompressSettingsProps {
parameters: CompressParameters; parameters: CompressParameters;
@@ -14,20 +13,6 @@ interface CompressSettingsProps {
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => { const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isSliding, setIsSliding] = useState(false); 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 ( return (
<Stack gap="md"> <Stack gap="md">
@@ -144,62 +129,6 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
disabled={disabled} disabled={disabled}
label={t("compress.grayscale.label", "Apply Grayscale for compression")} 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>
</Stack> </Stack>
); );
@@ -24,13 +24,6 @@ export const useCompressTips = (): TooltipContent => {
{ {
title: t("compress.tooltip.grayscale.title", "Grayscale"), 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.") 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."
)
} }
] ]
}; };
@@ -1,352 +0,0 @@
import { useImperativeHandle, forwardRef, useCallback } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, PdfAnnotationIcon } from '@embedpdf/models';
import type {
AnnotationToolId,
AnnotationToolOptions,
AnnotationAPI,
AnnotationEvent,
AnnotationPatch,
} from '@app/components/viewer/viewerTypes';
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
type AnnotationDefaults =
| {
type:
| PdfAnnotationSubtype.HIGHLIGHT
| PdfAnnotationSubtype.UNDERLINE
| PdfAnnotationSubtype.STRIKEOUT
| PdfAnnotationSubtype.SQUIGGLY;
color: string;
opacity: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.INK;
color: string;
opacity?: number;
borderWidth?: number;
strokeWidth?: number;
lineWidth?: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.FREETEXT;
fontColor?: string;
fontSize?: number;
fontFamily?: string;
textAlign?: number;
opacity?: number;
backgroundColor?: string;
borderWidth?: number;
contents?: string;
icon?: PdfAnnotationIcon;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.SQUARE | PdfAnnotationSubtype.CIRCLE | PdfAnnotationSubtype.POLYGON;
color: string;
strokeColor: string;
opacity: number;
fillOpacity: number;
strokeOpacity: number;
borderWidth: number;
strokeWidth: number;
lineWidth: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.LINE | PdfAnnotationSubtype.POLYLINE;
color: string;
strokeColor?: string;
opacity: number;
borderWidth?: number;
strokeWidth?: number;
lineWidth?: number;
startStyle?: string;
endStyle?: string;
lineEndingStyles?: { start: string; end: string };
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.STAMP;
imageSrc?: string;
imageSize?: { width: number; height: number };
customData?: Record<string, unknown>;
}
| null;
type AnnotationApiSurface = {
setActiveTool: (toolId: AnnotationToolId | null) => void;
getActiveTool?: () => { id: AnnotationToolId } | null;
setToolDefaults?: (toolId: AnnotationToolId, defaults: AnnotationDefaults) => void;
getSelectedAnnotation?: () => unknown | null;
deselectAnnotation?: () => void;
updateAnnotation?: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
};
type ToolDefaultsBuilder = (options?: AnnotationToolOptions) => AnnotationDefaults;
const NOTE_ICON_MAP: Record<NoteIcon, PdfAnnotationIcon> = {
Comment: PdfAnnotationIcon.Comment,
Key: PdfAnnotationIcon.Key,
Note: PdfAnnotationIcon.Note,
Help: PdfAnnotationIcon.Help,
NewParagraph: PdfAnnotationIcon.NewParagraph,
Paragraph: PdfAnnotationIcon.Paragraph,
Insert: PdfAnnotationIcon.Insert,
};
const DEFAULTS = {
highlight: '#ffd54f',
underline: '#ffb300',
strikeout: '#e53935',
squiggly: '#00acc1',
ink: '#1f2933',
inkHighlighter: '#ffd54f',
text: '#111111',
note: '#ffd54f', // match highlight color
shapeFill: '#0000ff',
shapeStroke: '#cf5b5b',
shapeOpacity: 0.5,
};
const withCustomData = (options?: AnnotationToolOptions) =>
options?.customData ? { customData: options.customData } : {};
const getIconEnum = (icon?: NoteIcon) => NOTE_ICON_MAP[icon ?? 'Comment'] ?? PdfAnnotationIcon.Comment;
const buildStampDefaults: ToolDefaultsBuilder = (options) => ({
type: PdfAnnotationSubtype.STAMP,
...(options?.imageSrc ? { imageSrc: options.imageSrc } : {}),
...(options?.imageSize ? { imageSize: options.imageSize } : {}),
...withCustomData(options),
});
const buildInkDefaults = (options?: AnnotationToolOptions, opacityOverride?: number): AnnotationDefaults => ({
type: PdfAnnotationSubtype.INK,
color: options?.color ?? (opacityOverride ? DEFAULTS.inkHighlighter : DEFAULTS.ink),
opacity: options?.opacity ?? opacityOverride ?? 1,
borderWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
strokeWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
lineWidth: options?.thickness ?? (opacityOverride ? 6 : 2),
...withCustomData(options),
});
const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
select: () => null,
highlight: (options) => ({
type: PdfAnnotationSubtype.HIGHLIGHT,
color: options?.color ?? DEFAULTS.highlight,
opacity: options?.opacity ?? 0.6,
...withCustomData(options),
}),
underline: (options) => ({
type: PdfAnnotationSubtype.UNDERLINE,
color: options?.color ?? DEFAULTS.underline,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
strikeout: (options) => ({
type: PdfAnnotationSubtype.STRIKEOUT,
color: options?.color ?? DEFAULTS.strikeout,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
squiggly: (options) => ({
type: PdfAnnotationSubtype.SQUIGGLY,
color: options?.color ?? DEFAULTS.squiggly,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
ink: (options) => buildInkDefaults(options),
inkHighlighter: (options) => buildInkDefaults(options, options?.opacity ?? 0.6),
text: (options) => ({
type: PdfAnnotationSubtype.FREETEXT,
fontColor: options?.color ?? DEFAULTS.text,
fontSize: options?.fontSize ?? 14,
fontFamily: options?.fontFamily ?? 'Helvetica',
textAlign: options?.textAlign ?? 0,
opacity: options?.opacity ?? 1,
borderWidth: options?.thickness ?? 1,
...(options?.fillColor ? { backgroundColor: options.fillColor } : {}),
...withCustomData(options),
}),
note: (options) => {
const backgroundColor = options?.fillColor ?? DEFAULTS.note;
const fontColor = options?.color ?? DEFAULTS.text;
return {
type: PdfAnnotationSubtype.FREETEXT,
fontColor,
color: fontColor,
fontFamily: options?.fontFamily ?? 'Helvetica',
textAlign: options?.textAlign ?? 0,
fontSize: options?.fontSize ?? 12,
opacity: options?.opacity ?? 1,
backgroundColor,
borderWidth: options?.thickness ?? 0,
contents: options?.contents ?? 'Note',
icon: getIconEnum(options?.icon),
...withCustomData(options),
};
},
square: (options) => ({
type: PdfAnnotationSubtype.SQUARE,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
circle: (options) => ({
type: PdfAnnotationSubtype.CIRCLE,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
line: (options) => ({
type: PdfAnnotationSubtype.LINE,
color: options?.color ?? '#1565c0',
strokeColor: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
strokeWidth: options?.borderWidth ?? 2,
lineWidth: options?.borderWidth ?? 2,
...withCustomData(options),
}),
lineArrow: (options) => ({
type: PdfAnnotationSubtype.LINE,
color: options?.color ?? '#1565c0',
strokeColor: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
strokeWidth: options?.borderWidth ?? 2,
lineWidth: options?.borderWidth ?? 2,
startStyle: 'None',
endStyle: 'ClosedArrow',
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
...withCustomData(options),
}),
polyline: (options) => ({
type: PdfAnnotationSubtype.POLYLINE,
color: options?.color ?? '#1565c0',
opacity: options?.opacity ?? 1,
borderWidth: options?.borderWidth ?? 2,
...withCustomData(options),
}),
polygon: (options) => ({
type: PdfAnnotationSubtype.POLYGON,
color: options?.color ?? DEFAULTS.shapeFill,
strokeColor: options?.strokeColor ?? DEFAULTS.shapeStroke,
opacity: options?.opacity ?? DEFAULTS.shapeOpacity,
fillOpacity: options?.fillOpacity ?? DEFAULTS.shapeOpacity,
strokeOpacity: options?.strokeOpacity ?? DEFAULTS.shapeOpacity,
borderWidth: options?.borderWidth ?? 1,
strokeWidth: options?.borderWidth ?? 1,
lineWidth: options?.borderWidth ?? 1,
...withCustomData(options),
}),
stamp: buildStampDefaults,
signatureStamp: buildStampDefaults,
signatureInk: (options) => buildInkDefaults(options),
};
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
const { provides: annotationApi } = useAnnotationCapability();
const buildAnnotationDefaults = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
TOOL_DEFAULT_BUILDERS[toolId]?.(options) ?? null,
[]
);
const configureAnnotationTool = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (!api?.setActiveTool) return;
const defaults = buildAnnotationDefaults(toolId, options);
// Reset tool first, then activate (like SignatureAPIBridge does)
api.setActiveTool(null);
api.setActiveTool(toolId === 'select' ? null : toolId);
// Verify tool was activated before setting defaults (like SignatureAPIBridge does)
const activeTool = api.getActiveTool?.();
if (activeTool && activeTool.id === toolId && defaults) {
api.setToolDefaults?.(toolId, defaults);
}
},
[annotationApi, buildAnnotationDefaults]
);
useImperativeHandle(
ref,
() => ({
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
configureAnnotationTool(toolId, options);
},
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const defaults = buildAnnotationDefaults(toolId, options);
const api = annotationApi as AnnotationApiSurface | undefined;
if (defaults && api?.setToolDefaults) {
api.setToolDefaults(toolId, defaults);
}
},
getSelectedAnnotation: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (!api?.getSelectedAnnotation) {
return null;
}
try {
return api.getSelectedAnnotation();
} catch (error) {
// Some EmbedPDF builds expose getSelectedAnnotation with an internal
// `this`/state dependency (e.g. reading `selectedUid` from undefined).
// If that happens, fail gracefully and treat it as "no selection"
// instead of crashing the entire annotations tool.
console.error('[AnnotationAPIBridge] getSelectedAnnotation failed:', error);
return null;
}
},
deselectAnnotation: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.deselectAnnotation?.();
},
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.updateAnnotation?.(pageIndex, annotationId, patch);
},
deactivateTools: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
api?.setActiveTool?.(null);
},
onAnnotationEvent: (listener: (event: AnnotationEvent) => void) => {
const api = annotationApi as AnnotationApiSurface | undefined;
if (api?.onAnnotationEvent) {
return api.onAnnotationEvent(listener);
}
return undefined;
},
getActiveTool: () => {
const api = annotationApi as AnnotationApiSurface | undefined;
return api?.getActiveTool?.() ?? null;
},
}),
[annotationApi, configureAnnotationTool, buildAnnotationDefaults]
);
return null;
});
@@ -51,11 +51,13 @@ const EmbedPdfViewerContent = ({
getScrollState, getScrollState,
getRotationState, getRotationState,
isAnnotationMode, isAnnotationMode,
setAnnotationMode,
isAnnotationsVisible, isAnnotationsVisible,
exportActions, exportActions,
} = useViewer(); } = useViewer();
// Register viewer right-rail buttons
useViewerRightRailButtons();
const scrollState = getScrollState(); const scrollState = getScrollState();
const rotationState = getRotationState(); const rotationState = getRotationState();
@@ -67,13 +69,8 @@ const EmbedPdfViewerContent = ({
} }
}, [rotationState.rotation]); }, [rotationState.rotation]);
// Get signature and annotation contexts // Get signature context
const { signatureApiRef, annotationApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature(); const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Track whether there are unsaved annotation changes in this viewer session.
// This is our source of truth for navigation guards; it is set when the
// annotation history changes, and cleared after we successfully apply changes.
const hasAnnotationChangesRef = useRef(false);
// Get current file from FileContext // Get current file from FileContext
const { selectors, state } = useFileState(); const { selectors, state } = useFileState();
@@ -85,18 +82,15 @@ const EmbedPdfViewerContent = ({
// Navigation guard for unsaved changes // Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
// Check if we're in an annotation tool // Check if we're in signature mode OR viewer annotation mode
const { selectedTool } = useNavigationState(); const { selectedTool } = useNavigationState();
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate) // Tools that use the stamp/signature placement system with hover preview
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate'; const isSignatureMode = 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( const isPlacementOverlayActive = Boolean(
isInAnnotationTool && isPlacementMode && signatureConfig isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
); );
// Track which file tab is active // Track which file tab is active
@@ -227,31 +221,6 @@ const EmbedPdfViewerContent = ({
}; };
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]); }, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
// Watch the annotation history API to detect when the document becomes "dirty".
// We treat any change that makes the history undoable as unsaved changes until
// the user explicitly applies them via applyChanges.
useEffect(() => {
const historyApi = historyApiRef.current;
if (!historyApi || !historyApi.subscribe) {
return;
}
const updateHasChanges = () => {
const canUndo = historyApi.canUndo?.() ?? false;
if (!hasAnnotationChangesRef.current && canUndo) {
hasAnnotationChangesRef.current = true;
setHasUnsavedChanges(true);
}
};
const unsubscribe = historyApi.subscribe(updateHasChanges);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, [historyApiRef.current, setHasUnsavedChanges]);
// Register checker for unsaved changes (annotations only for now) // Register checker for unsaved changes (annotations only for now)
useEffect(() => { useEffect(() => {
if (previewFile) { if (previewFile) {
@@ -259,28 +228,39 @@ const EmbedPdfViewerContent = ({
} }
const checkForChanges = () => { const checkForChanges = () => {
const hasAnnotationChanges = hasAnnotationChangesRef.current; // Check for annotation changes via history
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges
});
return hasAnnotationChanges; return hasAnnotationChanges;
}; };
console.log('[Viewer] Registering unsaved changes checker');
registerUnsavedChangesChecker(checkForChanges); registerUnsavedChangesChecker(checkForChanges);
return () => { return () => {
console.log('[Viewer] Unregistering unsaved changes checker');
unregisterUnsavedChangesChecker(); unregisterUnsavedChangesChecker();
}; };
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]); }, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
// Apply changes - save annotations to new file version // Apply changes - save annotations to new file version
const applyChanges = useCallback(async () => { const applyChanges = useCallback(async () => {
if (!currentFile || activeFileIds.length === 0) return; if (!currentFile || activeFileIds.length === 0) return;
try { try {
console.log('[Viewer] Applying changes - exporting PDF with annotations');
// Step 1: Export PDF with annotations using EmbedPDF // Step 1: Export PDF with annotations using EmbedPDF
const arrayBuffer = await exportActions.saveAsCopy(); const arrayBuffer = await exportActions.saveAsCopy();
if (!arrayBuffer) { if (!arrayBuffer) {
throw new Error('Failed to export PDF'); throw new Error('Failed to export PDF');
} }
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
// Step 2: Convert ArrayBuffer to File // Step 2: Convert ArrayBuffer to File
const blob = new Blob([arrayBuffer], { type: 'application/pdf' }); const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const filename = currentFile.name || 'document.pdf'; const filename = currentFile.name || 'document.pdf';
@@ -295,29 +275,12 @@ const EmbedPdfViewerContent = ({
// Step 4: Consume files (replace in context) // Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
// Mark annotations as saved so navigation away from the viewer is allowed.
hasAnnotationChangesRef.current = false;
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
} catch (error) { } catch (error) {
console.error('Apply changes failed:', error); console.error('Apply changes failed:', error);
} }
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]); }, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
// Expose annotation apply via a global event so tools (like Annotate) can
// trigger saves from the left sidebar without tight coupling.
useEffect(() => {
const handler = () => {
void applyChanges();
};
window.addEventListener('stirling-annotations-apply', handler);
return () => {
window.removeEventListener('stirling-annotations-apply', handler);
};
}, [applyChanges]);
// Register viewer right-rail buttons
useViewerRightRailButtons();
const sidebarWidthRem = 15; const sidebarWidthRem = 15;
const totalRightMargin = const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0); (isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
@@ -370,10 +333,8 @@ const EmbedPdfViewerContent = ({
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)} key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file} file={effectiveFile.file}
url={effectiveFile.url} url={effectiveFile.url}
enableAnnotations={isAnnotationMode} enableAnnotations={shouldEnableAnnotations}
showBakedAnnotations={isAnnotationsVisible}
signatureApiRef={signatureApiRef as React.RefObject<any>} signatureApiRef={signatureApiRef as React.RefObject<any>}
annotationApiRef={annotationApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>} historyApiRef={historyApiRef as React.RefObject<any>}
onSignatureAdded={() => { onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed // Handle signature added - for debugging, enable console logs as needed
@@ -38,9 +38,8 @@ import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge';
import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge'; import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge';
import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge'; import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge'; import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
import { AnnotationAPIBridge } from '@app/components/viewer/AnnotationAPIBridge';
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge'; import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
import type { SignatureAPI, AnnotationAPI, HistoryAPI } from '@app/components/viewer/viewerTypes'; import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge'; import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge'; import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge'; import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
@@ -53,14 +52,12 @@ interface LocalEmbedPDFProps {
file?: File | Blob; file?: File | Blob;
url?: string | null; url?: string | null;
enableAnnotations?: boolean; enableAnnotations?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void; onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>; signatureApiRef?: React.RefObject<SignatureAPI>;
annotationApiRef?: React.RefObject<AnnotationAPI>;
historyApiRef?: React.RefObject<HistoryAPI>; historyApiRef?: React.RefObject<HistoryAPI>;
} }
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef }: LocalEmbedPDFProps) { export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null); const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]); const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@@ -103,7 +100,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
}), }),
createPluginRegistration(RenderPluginPackage, { createPluginRegistration(RenderPluginPackage, {
withForms: true, withForms: true,
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF withAnnotations: true,
}), }),
// Register interaction manager (required for zoom and selection features) // Register interaction manager (required for zoom and selection features)
@@ -125,8 +122,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
selectAfterCreate: true, selectAfterCreate: true,
}), }),
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning // Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {}), createPluginRegistration(PanPluginPackage, {
defaultMode: 'mobile', // Try mobile mode which might be more permissive
}),
// Register zoom plugin with configuration // Register zoom plugin with configuration
createPluginRegistration(ZoomPluginPackage, { createPluginRegistration(ZoomPluginPackage, {
@@ -167,7 +166,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
// Register print plugin for printing PDFs // Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage), createPluginRegistration(PrintPluginPackage),
]; ];
}, [pdfUrl, enableAnnotations, showBakedAnnotations]); }, [pdfUrl]);
// Initialize the engine with the React hook - use local WASM for offline support // Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({ const { engine, isLoading, error } = usePdfiumEngine({
@@ -252,315 +251,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
if (!annotationApi) return; if (!annotationApi) return;
if (enableAnnotations) { if (enableAnnotations) {
const ensureTool = (tool: any) => { annotationApi.addTool({
const existing = annotationApi.getTool?.(tool.id);
if (!existing) {
annotationApi.addTool(tool);
}
};
ensureTool({
id: 'highlight',
name: 'Highlight',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.HIGHLIGHT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.HIGHLIGHT,
color: '#ffd54f',
opacity: 0.6,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'underline',
name: 'Underline',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.UNDERLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.UNDERLINE,
color: '#ffb300',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'strikeout',
name: 'Strikeout',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STRIKEOUT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.STRIKEOUT,
color: '#e53935',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'squiggly',
name: 'Squiggly',
interaction: { exclusive: true, cursor: 'text', textSelection: true },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUIGGLY ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUIGGLY,
color: '#00acc1',
opacity: 1,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'ink',
name: 'Pen',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#1f2933',
opacity: 1,
borderWidth: 2,
lineWidth: 2,
strokeWidth: 2,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'inkHighlighter',
name: 'Ink Highlighter',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.INK && annotation.color === '#ffd54f' ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#ffd54f',
opacity: 0.5,
borderWidth: 6,
lineWidth: 6,
strokeWidth: 6,
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'square',
name: 'Square',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.SQUARE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.SQUARE,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
strokeWidth: 1,
lineWidth: 1,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 120, height: 90 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'circle',
name: 'Circle',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.CIRCLE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.CIRCLE,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
strokeWidth: 1,
lineWidth: 1,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 100, height: 100 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'line',
name: 'Line',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
strokeWidth: 2,
lineWidth: 2,
},
clickBehavior: {
enabled: true,
defaultLength: 120,
defaultAngle: 0,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'lineArrow',
name: 'Arrow',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.LINE && (annotation.endStyle === 'ClosedArrow' || annotation.lineEndingStyles?.end === 'ClosedArrow') ? 9 : 0),
defaults: {
type: PdfAnnotationSubtype.LINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
startStyle: 'None',
endStyle: 'ClosedArrow',
lineEndingStyles: { start: 'None', end: 'ClosedArrow' },
},
clickBehavior: {
enabled: true,
defaultLength: 120,
defaultAngle: 0,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'polyline',
name: 'Polyline',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYLINE ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYLINE,
color: '#1565c0',
opacity: 1,
borderWidth: 2,
},
clickBehavior: {
enabled: true,
finishOnDoubleClick: true,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'polygon',
name: 'Polygon',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.POLYGON ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.POLYGON,
color: '#0000ff', // fill color (blue)
strokeColor: '#cf5b5b', // border color (reddish pink)
opacity: 0.5,
borderWidth: 1,
},
clickBehavior: {
enabled: true,
finishOnDoubleClick: true,
defaultSize: { width: 140, height: 100 },
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'text',
name: 'Text',
interaction: { exclusive: true, cursor: 'text' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 10 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#111111',
fontSize: 14,
fontFamily: 'Helvetica',
opacity: 1,
interiorColor: '#fffef7',
contents: 'Text',
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'note',
name: 'Note',
interaction: { exclusive: true, cursor: 'pointer' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.FREETEXT ? 8 : 0),
defaults: {
type: PdfAnnotationSubtype.FREETEXT,
textColor: '#1b1b1b',
color: '#ffa000',
interiorColor: '#fff8e1',
opacity: 1,
contents: 'Note',
fontSize: 12,
},
clickBehavior: {
enabled: true,
defaultSize: { width: 160, height: 100 },
},
behavior: {
deactivateToolAfterCreate: false,
selectAfterCreate: true,
},
});
ensureTool({
id: 'stamp',
name: 'Image Stamp',
interaction: { exclusive: false, cursor: 'copy' },
matchScore: (annotation: any) => (annotation.type === PdfAnnotationSubtype.STAMP ? 5 : 0),
defaults: {
type: PdfAnnotationSubtype.STAMP,
},
behavior: {
deactivateToolAfterCreate: true,
selectAfterCreate: true,
},
});
ensureTool({
id: 'signatureStamp', id: 'signatureStamp',
name: 'Digital Signature', name: 'Digital Signature',
interaction: { exclusive: false, cursor: 'copy' }, interaction: { exclusive: false, cursor: 'copy' },
@@ -570,7 +261,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
}, },
}); });
ensureTool({ annotationApi.addTool({
id: 'signatureInk', id: 'signatureInk',
name: 'Signature Draw', name: 'Signature Draw',
interaction: { exclusive: true, cursor: 'crosshair' }, interaction: { exclusive: true, cursor: 'crosshair' },
@@ -618,7 +309,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
<ThumbnailAPIBridge /> <ThumbnailAPIBridge />
<RotateAPIBridge /> <RotateAPIBridge />
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />} {enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />} {enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge /> <ExportAPIBridge />
<BookmarkAPIBridge /> <BookmarkAPIBridge />
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { Button, Paper, Group, NumberInput } from '@mantine/core'; import { Button, Paper, Group, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext'; import { useViewer } from '@app/contexts/ViewerContext';
import { Tooltip } from '@app/components/shared/Tooltip';
import FirstPageIcon from '@mui/icons-material/FirstPage'; import FirstPageIcon from '@mui/icons-material/FirstPage';
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
@@ -210,27 +209,21 @@ export function PdfViewerToolbar({
</Button> </Button>
{/* Dual Page Toggle */} {/* Dual Page Toggle */}
<Tooltip <Button
content={ variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={handleDualPageToggle}
style={{ minWidth: '2.5rem' }}
title={
isDualPageActive isDualPageActive
? t("viewer.singlePageView", "Single Page View") ? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View") : t("viewer.dualPageView", "Dual Page View")
} }
position="top"
arrow
> >
<Button {isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
variant={isDualPageActive ? "filled" : "light"} </Button>
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 */} {/* Zoom Controls */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}> <Group gap={4} align="center" style={{ marginLeft: 16 }}>
@@ -104,20 +104,12 @@ const createTextStampImage = (
ctx.fillStyle = textColor; ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`; ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = config.textAlign || 'left'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const horizontalPadding = paddingX; const horizontalPadding = paddingX;
const verticalCenter = naturalHeight / 2; const verticalCenter = naturalHeight / 2;
ctx.fillText(text, horizontalPadding, verticalCenter);
let xPosition = horizontalPadding;
if (config.textAlign === 'center') {
xPosition = naturalWidth / 2;
} else if (config.textAlign === 'right') {
xPosition = naturalWidth - horizontalPadding;
}
ctx.fillText(text, xPosition, verticalCenter);
return { return {
dataUrl: canvas.toDataURL('image/png'), dataUrl: canvas.toDataURL('image/png'),
@@ -207,21 +199,12 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
} }
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]); }, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations // Enable keyboard deletion of selected annotations
useEffect(() => { useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode // Always enable delete key when we have annotation API and are in sign mode
if (!annotationApi || (isPlacementMode === undefined)) return; if (!annotationApi || (isPlacementMode === undefined)) return;
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
const target = event.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase();
const editable = target?.getAttribute?.('contenteditable');
if (tag === 'input' || tag === 'textarea' || editable === 'true') {
return;
}
if (event.key === 'Delete' || event.key === 'Backspace') { if (event.key === 'Delete' || event.key === 'Backspace') {
const selectedAnnotation = annotationApi.getSelectedAnnotation?.(); const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect, useCallback } from 'react'; import { useMemo, useState } from 'react';
import { ActionIcon, Popover } from '@mantine/core'; import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext'; import { useViewer } from '@app/contexts/ViewerContext';
@@ -9,9 +9,6 @@ import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls'; import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { BASE_PATH, withBasePath } from '@app/constants/app';
export function useViewerRightRailButtons() { export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -19,32 +16,6 @@ export function useViewerRightRailButtons() {
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false); const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext(); const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12); const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
const { handleToolSelect } = useToolWorkflow();
const { selectedTool } = useNavigationState();
const stripBasePath = useCallback((path: string) => {
if (BASE_PATH && path.startsWith(BASE_PATH)) {
return path.slice(BASE_PATH.length) || '/';
}
return path;
}, []);
const isAnnotationsPath = useCallback(() => {
const cleanPath = stripBasePath(window.location.pathname).toLowerCase();
return cleanPath === '/annotations' || cleanPath.endsWith('/annotations');
}, [stripBasePath]);
const [isAnnotationsActive, setIsAnnotationsActive] = useState<boolean>(() => isAnnotationsPath());
useEffect(() => {
setIsAnnotationsActive(isAnnotationsPath());
}, [selectedTool, isAnnotationsPath]);
useEffect(() => {
const handlePopState = () => setIsAnnotationsActive(isAnnotationsPath());
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isAnnotationsPath]);
// Lift i18n labels out of memo for clarity // Lift i18n labels out of memo for clarity
const searchLabel = t('rightRail.search', 'Search PDF'); const searchLabel = t('rightRail.search', 'Search PDF');
@@ -54,11 +25,9 @@ export function useViewerRightRailButtons() {
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar'); const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks'); const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const printLabel = t('rightRail.print', 'Print PDF'); const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => { const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
const buttons: RightRailButtonWithAction[] = [ return [
{ {
id: 'viewer-search', id: 'viewer-search',
tooltip: searchLabel, tooltip: searchLabel,
@@ -178,36 +147,6 @@ export function useViewerRightRailButtons() {
viewer.printActions.print(); viewer.printActions.print();
} }
}, },
{
id: 'viewer-annotations',
tooltip: annotationsLabel,
ariaLabel: annotationsLabel,
section: 'top' as const,
order: 58,
render: ({ disabled }) => (
<Tooltip content={annotationsLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isAnnotationsActive ? 'default' : 'subtle'}
radius="md"
className="right-rail-icon"
onClick={() => {
if (disabled || isAnnotationsActive) return;
const targetPath = withBasePath('/annotations');
if (window.location.pathname !== targetPath) {
window.history.pushState(null, '', targetPath);
}
setIsAnnotationsActive(true);
handleToolSelect('annotate');
}}
disabled={disabled || isAnnotationsActive}
aria-pressed={isAnnotationsActive}
style={isAnnotationsActive ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)
},
{ {
id: 'viewer-annotation-controls', id: 'viewer-annotation-controls',
section: 'top' as const, section: 'top' as const,
@@ -215,30 +154,9 @@ export function useViewerRightRailButtons() {
render: ({ disabled }) => ( render: ({ disabled }) => (
<ViewerAnnotationControls currentView="viewer" disabled={disabled} /> <ViewerAnnotationControls currentView="viewer" disabled={disabled} />
) )
}, }
]; ];
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
// Optional: Save button for annotations (always registered when this hook is used
// with a save handler; uses a ref to avoid infinite re-registration loops).
return buttons;
}, [
t,
i18n.language,
viewer,
isPanning,
searchLabel,
panLabel,
rotateLeftLabel,
rotateRightLabel,
sidebarLabel,
bookmarkLabel,
printLabel,
tooltipPosition,
annotationsLabel,
saveChangesLabel,
isAnnotationsActive,
handleToolSelect,
]);
useRightRailButtons(viewerButtons); useRightRailButtons(viewerButtons);
} }
@@ -16,17 +16,6 @@ export interface SignatureAPI {
getPageAnnotations: (pageIndex: number) => Promise<any[]>; getPageAnnotations: (pageIndex: number) => Promise<any[]>;
} }
export interface AnnotationAPI {
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => void;
getSelectedAnnotation: () => AnnotationSelection | null;
deselectAnnotation: () => void;
updateAnnotation: (pageIndex: number, annotationId: string, patch: AnnotationPatch) => void;
deactivateTools: () => void;
onAnnotationEvent?: (listener: (event: AnnotationEvent) => void) => void | (() => void);
getActiveTool?: () => { id: AnnotationToolId } | null;
}
export interface HistoryAPI { export interface HistoryAPI {
undo: () => void; undo: () => void;
redo: () => void; redo: () => void;
@@ -34,50 +23,3 @@ export interface HistoryAPI {
canRedo: () => boolean; canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void; subscribe?: (listener: () => void) => () => void;
} }
export type AnnotationToolId =
| 'select'
| 'highlight'
| 'underline'
| 'strikeout'
| 'squiggly'
| 'ink'
| 'inkHighlighter'
| 'text'
| 'note'
| 'square'
| 'circle'
| 'line'
| 'lineArrow'
| 'polyline'
| 'polygon'
| 'stamp'
| 'signatureStamp'
| 'signatureInk';
export interface AnnotationEvent {
type: string;
[key: string]: unknown;
}
export type AnnotationPatch = Record<string, unknown>;
export type AnnotationSelection = unknown;
export interface AnnotationToolOptions {
color?: string;
fillColor?: string;
strokeColor?: string;
opacity?: number;
strokeOpacity?: number;
fillOpacity?: number;
thickness?: number;
borderWidth?: number;
fontSize?: number;
fontFamily?: string;
textAlign?: number; // 0 = Left, 1 = Center, 2 = Right
imageSrc?: string;
imageSize?: { width: number; height: number };
icon?: 'Comment' | 'Key' | 'Note' | 'Help' | 'NewParagraph' | 'Paragraph' | 'Insert';
contents?: string;
customData?: Record<string, unknown>;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface UpgradeBannerAlertPayload {
freeTierLimit?: number; freeTierLimit?: number;
} }
export type TourType = 'admin' | 'tools' | 'whatsnew'; export type TourType = 'admin' | 'tools';
export interface StartTourPayload { export interface StartTourPayload {
tourType: TourType; tourType: TourType;
@@ -1,26 +0,0 @@
import React, { createContext, useContext, ReactNode, useRef } from 'react';
import type { AnnotationAPI } from '@app/components/viewer/viewerTypes';
interface AnnotationContextValue {
annotationApiRef: React.RefObject<AnnotationAPI | null>;
}
const AnnotationContext = createContext<AnnotationContextValue | undefined>(undefined);
export const AnnotationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const annotationApiRef = useRef<AnnotationAPI>(null);
const value: AnnotationContextValue = {
annotationApiRef,
};
return <AnnotationContext.Provider value={value}>{children}</AnnotationContext.Provider>;
};
export const useAnnotation = (): AnnotationContextValue => {
const context = useContext(AnnotationContext);
if (!context) {
throw new Error('useAnnotation must be used within an AnnotationProvider');
}
return context;
};
@@ -29,7 +29,6 @@ export interface AppConfig {
enableAnalytics?: boolean | null; enableAnalytics?: boolean | null;
enablePosthog?: boolean | null; enablePosthog?: boolean | null;
enableScarf?: boolean | null; enableScarf?: boolean | null;
enableDesktopInstallSlide?: boolean;
premiumEnabled?: boolean; premiumEnabled?: boolean;
premiumKey?: string; premiumKey?: string;
termsAndConditions?: string; termsAndConditions?: string;
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react'; import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import type { SignatureAPI, HistoryAPI, AnnotationAPI } from '@app/components/viewer/viewerTypes'; import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
// Signature state interface // Signature state interface
interface SignatureState { interface SignatureState {
@@ -34,7 +34,6 @@ interface SignatureActions {
// Combined context interface // Combined context interface
interface SignatureContextValue extends SignatureState, SignatureActions { interface SignatureContextValue extends SignatureState, SignatureActions {
signatureApiRef: React.RefObject<SignatureAPI | null>; signatureApiRef: React.RefObject<SignatureAPI | null>;
annotationApiRef: React.RefObject<AnnotationAPI | null>;
historyApiRef: React.RefObject<HistoryAPI | null>; historyApiRef: React.RefObject<HistoryAPI | null>;
} }
@@ -53,7 +52,6 @@ const initialState: SignatureState = {
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, setState] = useState<SignatureState>(initialState); const [state, setState] = useState<SignatureState>(initialState);
const signatureApiRef = useRef<SignatureAPI>(null); const signatureApiRef = useRef<SignatureAPI>(null);
const annotationApiRef = useRef<AnnotationAPI>(null);
const historyApiRef = useRef<HistoryAPI>(null); const historyApiRef = useRef<HistoryAPI>(null);
const imageDataStore = useRef<Map<string, string>>(new Map()); const imageDataStore = useRef<Map<string, string>>(new Map());
@@ -159,7 +157,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
const contextValue: SignatureContextValue = { const contextValue: SignatureContextValue = {
...state, ...state,
signatureApiRef, signatureApiRef,
annotationApiRef,
historyApiRef, historyApiRef,
setSignatureConfig, setSignatureConfig,
setPlacementMode, setPlacementMode,
@@ -111,8 +111,6 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
const loadSampleFile = useCallback(async () => { const loadSampleFile = useCallback(async () => {
try { 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 response = await fetch(`${BASE_PATH}/samples/Sample.pdf`);
const blob = await response.blob(); const blob = await response.blob();
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' }); const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
@@ -95,6 +95,7 @@ interface ViewerContextType {
// Annotation/drawing mode for viewer // Annotation/drawing mode for viewer
isAnnotationMode: boolean; isAnnotationMode: boolean;
setAnnotationMode: (enabled: boolean) => void; setAnnotationMode: (enabled: boolean) => void;
toggleAnnotationMode: () => void;
// Active file index for multi-file viewing // Active file index for multi-file viewing
activeFileIndex: number; activeFileIndex: number;
@@ -229,6 +230,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsAnnotationModeState(enabled); setIsAnnotationModeState(enabled);
}; };
const toggleAnnotationMode = () => {
setIsAnnotationModeState(prev => !prev);
};
// State getters - read from bridge refs // State getters - read from bridge refs
const getScrollState = (): ScrollState => { const getScrollState = (): ScrollState => {
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 }; return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
@@ -313,6 +318,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
toggleAnnotationsVisibility, toggleAnnotationsVisibility,
isAnnotationMode, isAnnotationMode,
setAnnotationMode, setAnnotationMode,
toggleAnnotationMode,
// Active file index // Active file index
activeFileIndex, activeFileIndex,
@@ -51,7 +51,6 @@ import Crop from "@app/tools/Crop";
import Sign from "@app/tools/Sign"; import Sign from "@app/tools/Sign";
import AddText from "@app/tools/AddText"; import AddText from "@app/tools/AddText";
import AddImage from "@app/tools/AddImage"; import AddImage from "@app/tools/AddImage";
import Annotate from "@app/tools/Annotate";
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
@@ -247,19 +246,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, 'addImage'), synonyms: getSynonyms(t, 'addImage'),
supportsAutomate: false, supportsAutomate: false,
}, },
annotate: {
icon: <LocalIcon icon="edit" width="1.5rem" height="1.5rem" />,
name: t('home.annotate.title', 'Annotate'),
component: Annotate,
description: t('home.annotate.desc', 'Highlight, draw, add notes, and shapes directly in the viewer'),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
workbench: 'viewer',
operationConfig: signOperationConfig,
automationSettings: null,
synonyms: getSynonyms(t, 'annotate'),
supportsAutomate: false,
},
// Document Security // Document Security
@@ -19,11 +19,6 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File
} }
formData.append("grayscale", parameters.grayscale.toString()); 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; return formData;
}; };
@@ -4,9 +4,6 @@ import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/u
export interface CompressParameters extends BaseParameters { export interface CompressParameters extends BaseParameters {
compressionLevel: number; compressionLevel: number;
grayscale: boolean; grayscale: boolean;
lineArt: boolean;
lineArtThreshold: number;
lineArtEdgeLevel: 1 | 2 | 3;
expectedSize: string; expectedSize: string;
compressionMethod: 'quality' | 'filesize'; compressionMethod: 'quality' | 'filesize';
fileSizeValue: string; fileSizeValue: string;
@@ -16,9 +13,6 @@ export interface CompressParameters extends BaseParameters {
export const defaultParameters: CompressParameters = { export const defaultParameters: CompressParameters = {
compressionLevel: 5, compressionLevel: 5,
grayscale: false, grayscale: false,
lineArt: false,
lineArtThreshold: 50,
lineArtEdgeLevel: 3,
expectedSize: '', expectedSize: '',
compressionMethod: 'quality', compressionMethod: 'quality',
fileSizeValue: '', fileSizeValue: '',
@@ -18,7 +18,6 @@ export interface SignParameters {
fontFamily?: string; fontFamily?: string;
fontSize?: number; fontSize?: number;
textColor?: string; textColor?: string;
textAlign?: 'left' | 'center' | 'right';
} }
export const DEFAULT_PARAMETERS: SignParameters = { export const DEFAULT_PARAMETERS: SignParameters = {
@@ -29,7 +28,6 @@ export const DEFAULT_PARAMETERS: SignParameters = {
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
fontSize: 16, fontSize: 16,
textColor: '#000000', textColor: '#000000',
textAlign: 'left',
}; };
const validateSignParameters = (parameters: SignParameters): boolean => { const validateSignParameters = (parameters: SignParameters): boolean => {
+23 -11
View File
@@ -93,15 +93,6 @@ export function useAdminSettings<T = any>(
try { try {
setSaving(true); setSaving(true);
// Compute delta: only include fields that changed from original
const delta = computeDelta(originalSettings, settings);
console.log(`[useAdminSettings:${sectionName}] Delta (changed fields):`, JSON.stringify(delta, null, 2));
if (Object.keys(delta).length === 0) {
console.log(`[useAdminSettings:${sectionName}] No changes detected, skipping save`);
return;
}
if (saveTransformer) { if (saveTransformer) {
// Use custom save logic for complex sections // Use custom save logic for complex sections
const { sectionData, deltaSettings } = saveTransformer(settings); const { sectionData, deltaSettings } = saveTransformer(settings);
@@ -111,12 +102,14 @@ export function useAdminSettings<T = any>(
// Save section data (with delta applied) - compare transformed vs transformed // Save section data (with delta applied) - compare transformed vs transformed
const sectionDelta = computeDelta(originalSectionData, sectionData); const sectionDelta = computeDelta(originalSectionData, sectionData);
if (Object.keys(sectionDelta).length > 0) { const hasSectionDelta = Object.keys(sectionDelta).length > 0;
if (hasSectionDelta) {
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta); await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta);
} }
// Save delta settings if provided (filter to only changed values) // Save delta settings if provided (filter to only changed values)
if (deltaSettings && Object.keys(deltaSettings).length > 0) { const hasDeltaSettings = deltaSettings && Object.keys(deltaSettings).length > 0;
if (hasDeltaSettings) {
// Build deltaSettings from original using same transformer to get correct structure // Build deltaSettings from original using same transformer to get correct structure
const { deltaSettings: originalDeltaSettings } = saveTransformer(originalSettings); const { deltaSettings: originalDeltaSettings } = saveTransformer(originalSettings);
@@ -146,8 +139,27 @@ export function useAdminSettings<T = any>(
} else { } else {
console.log(`[useAdminSettings:${sectionName}] No delta settings changed, skipping`); console.log(`[useAdminSettings:${sectionName}] No delta settings changed, skipping`);
} }
} else {
console.log(`[useAdminSettings:${sectionName}] No delta settings provided or changed, skipping delta save`);
}
if (!hasSectionDelta && !hasDeltaSettings) {
console.log(`[useAdminSettings:${sectionName}] No changes detected after transformation, skipping save`);
return;
} }
} else { } else {
// Compute delta: only include fields that changed from original
const delta = computeDelta(originalSettings, settings);
console.log(
`[useAdminSettings:${sectionName}] Delta (changed fields):`,
JSON.stringify(delta, null, 2)
);
if (Object.keys(delta).length === 0) {
console.log(`[useAdminSettings:${sectionName}] No changes detected, skipping save`);
return;
}
// Simple single-endpoint save with delta // Simple single-endpoint save with delta
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, delta); await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, delta);
} }
@@ -64,10 +64,7 @@ export function useServerExperience(): ServerExperienceValue {
const loginEnabled = config?.enableLogin !== false; const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin); const configIsAdmin = Boolean(config?.isAdmin);
// For no-login servers, treat everyone as a regular user (no effective admin) const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
// 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 hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE';
const setSelfReportedAdmin = useCallback((value: boolean) => { const setSelfReportedAdmin = useCallback((value: boolean) => {
+1 -3
View File
@@ -49,9 +49,7 @@ const auditService = {
* Get audit system status * Get audit system status
*/ */
async getSystemStatus(): Promise<AuditSystemStatus> { async getSystemStatus(): Promise<AuditSystemStatus> {
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', { const response = await apiClient.get<any>('/api/v1/proprietary/ui-data/audit-dashboard');
suppressErrorToast: true,
});
const data = response.data; const data = response.data;
// Map V1 response to expected format // Map V1 response to expected format
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = { const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true, enableAnalytics: true,
appVersion: '2.1.3', appVersion: '2.0.0',
serverCertificateEnabled: false, serverCertificateEnabled: false,
enableAlphaFunctionality: false, enableAlphaFunctionality: false,
serverPort: 8080, serverPort: 8080,
-416
View File
@@ -1,416 +0,0 @@
import { useEffect, useState, useContext, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import { useNavigation } from '@app/contexts/NavigationContext';
import { useFileSelection } from '@app/contexts/FileContext';
import { BaseToolProps } from '@app/types/tool';
import { useSignature } from '@app/contexts/SignatureContext';
import { ViewerContext, useViewer } from '@app/contexts/ViewerContext';
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
import { useAnnotationStyleState } from '@app/tools/annotate/useAnnotationStyleState';
import { useAnnotationSelection } from '@app/tools/annotate/useAnnotationSelection';
import { AnnotationPanel } from '@app/tools/annotate/AnnotationPanel';
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
'select',
'highlight',
'underline',
'strikeout',
'squiggly',
'ink',
'inkHighlighter',
'text',
'note',
'square',
'circle',
'line',
'lineArrow',
'polyline',
'polygon',
'stamp',
'signatureStamp',
'signatureInk',
];
const isKnownAnnotationTool = (toolId: string | undefined | null): toolId is AnnotationToolId =>
!!toolId && (KNOWN_ANNOTATION_TOOLS as string[]).includes(toolId);
const Annotate = (_props: BaseToolProps) => {
const { t } = useTranslation();
const { selectedTool, workbench, hasUnsavedChanges } = useNavigation();
const { selectedFiles } = useFileSelection();
const {
signatureApiRef,
annotationApiRef,
historyApiRef,
undo,
redo,
setSignatureConfig,
setPlacementMode,
placementPreviewSize,
setPlacementPreviewSize,
} = useSignature();
const viewerContext = useContext(ViewerContext);
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
const activeToolRef = useRef<AnnotationToolId>('select');
const wasAnnotateActiveRef = useRef<boolean>(false);
const [selectedTextDraft, setSelectedTextDraft] = useState<string>('');
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
const [stampImageData, setStampImageData] = useState<string | undefined>();
const [stampImageSize, setStampImageSize] = useState<{ width: number; height: number } | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const manualToolSwitch = useRef<boolean>(false);
// Zoom tracking for stamp size conversion
const [currentZoom, setCurrentZoom] = useState(() => {
const zoomState = getZoomState();
if (!zoomState) return 1;
if (typeof zoomState.zoomPercent === 'number') {
return Math.max(zoomState.zoomPercent / 100, 0.01);
}
return Math.max(zoomState.currentZoom ?? 1, 0.01);
});
useEffect(() => {
return registerImmediateZoomUpdate((newZoomPercent) => {
setCurrentZoom(Math.max(newZoomPercent / 100, 0.01));
});
}, [registerImmediateZoomUpdate]);
useEffect(() => {
activeToolRef.current = activeTool;
}, [activeTool]);
// CSS to PDF size conversion accounting for zoom
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
const factor = 1 / zoom;
return {
width: size.width * factor,
height: size.height * factor,
};
},
[currentZoom]
);
const computeStampDisplaySize = useCallback((natural: { width: number; height: number } | null) => {
if (!natural) {
return { width: 180, height: 120 };
}
const maxSide = 260;
const minSide = 24;
const { width, height } = natural;
const largest = Math.max(width || maxSide, height || maxSide, 1);
const scale = Math.min(1, maxSide / largest);
return {
width: Math.max(minSide, Math.round(width * scale)),
height: Math.max(minSide, Math.round(height * scale)),
};
}, []);
const {
styleState,
styleActions,
buildToolOptions,
getActiveColor,
} = useAnnotationStyleState(cssToPdfSize);
const {
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
} = styleActions;
const handleApplyChanges = useCallback(() => {
window.dispatchEvent(new CustomEvent('stirling-annotations-apply'));
}, []);
useEffect(() => {
const isAnnotateActive = workbench === 'viewer' && selectedTool === 'annotate';
if (wasAnnotateActiveRef.current && !isAnnotateActive) {
annotationApiRef?.current?.deactivateTools?.();
signatureApiRef?.current?.deactivateTools?.();
setPlacementMode(false);
} else if (!wasAnnotateActiveRef.current && isAnnotateActive) {
// When entering annotate mode, activate the select tool by default
const toolOptions = buildToolOptions('select');
annotationApiRef?.current?.activateAnnotationTool?.('select', toolOptions);
}
wasAnnotateActiveRef.current = isAnnotateActive;
}, [workbench, selectedTool, annotationApiRef, signatureApiRef, setPlacementMode, buildToolOptions]);
// Monitor history state for undo/redo availability
useEffect(() => {
const historyApi = historyApiRef?.current;
if (!historyApi) return;
const updateAvailability = () =>
setHistoryAvailability({
canUndo: historyApi.canUndo?.() ?? false,
canRedo: historyApi.canRedo?.() ?? false,
});
updateAvailability();
let interval: ReturnType<typeof setInterval> | undefined;
if (!historyApi.subscribe) {
// Fallback polling in case the history API doesn't support subscriptions
interval = setInterval(updateAvailability, 350);
} else {
const unsubscribe = historyApi.subscribe(updateAvailability);
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
if (interval) clearInterval(interval);
};
}
return () => {
if (interval) clearInterval(interval);
};
}, [historyApiRef?.current]);
useEffect(() => {
if (!viewerContext) return;
if (viewerContext.isAnnotationMode) return;
viewerContext.setAnnotationMode(true);
const toolOptions =
activeTool === 'stamp'
? buildToolOptions('stamp', { stampImageData, stampImageSize })
: buildToolOptions(activeTool);
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, toolOptions);
}, [viewerContext?.isAnnotationMode, signatureApiRef, activeTool, buildToolOptions, stampImageData, stampImageSize]);
const activateAnnotationTool = (toolId: AnnotationToolId) => {
// If leaving stamp tool, clean up placement mode
if (activeTool === 'stamp' && toolId !== 'stamp') {
setPlacementMode(false);
setSignatureConfig(null);
}
viewerContext?.setAnnotationMode(true);
// Mark as manual tool switch to prevent auto-switch back
manualToolSwitch.current = true;
// Deselect annotation in the viewer first
annotationApiRef?.current?.deselectAnnotation?.();
// Clear selection state to show default controls
setSelectedAnn(null);
setSelectedAnnId(null);
// Change the tool
setActiveTool(toolId);
const options =
toolId === 'stamp'
? buildToolOptions('stamp', { stampImageData, stampImageSize })
: buildToolOptions(toolId);
// For stamp, apply the image if we have one
annotationApiRef?.current?.setAnnotationStyle?.(toolId, options);
annotationApiRef?.current?.activateAnnotationTool?.(toolId === 'stamp' ? 'stamp' : toolId, options);
// Reset flag after a short delay
setTimeout(() => {
manualToolSwitch.current = false;
}, 300);
};
useEffect(() => {
// push style updates to EmbedPDF when sliders/colors change
if (activeTool === 'stamp') {
const options = buildToolOptions('stamp', { stampImageData, stampImageSize });
annotationApiRef?.current?.setAnnotationStyle?.('stamp', options);
} else {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
}
}, [activeTool, buildToolOptions, signatureApiRef, stampImageData, stampImageSize]);
// Sync preview size from overlay to annotation engine
useEffect(() => {
// When preview size changes, update stamp annotation sizing
// The SignatureAPIBridge will use placementPreviewSize from SignatureContext
// and apply the converted size to the stamp tool automatically
if (activeTool === 'stamp' && stampImageData) {
const size = placementPreviewSize ?? stampImageSize;
const stampOptions = buildToolOptions('stamp', { stampImageData, stampImageSize: size ?? null });
annotationApiRef?.current?.setAnnotationStyle?.('stamp', stampOptions);
}
}, [placementPreviewSize, activeTool, stampImageData, signatureApiRef, stampImageSize, cssToPdfSize, buildToolOptions]);
// Allow exiting multi-point tools with Escape (e.g., polyline)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (['polyline', 'polygon'].includes(activeTool)) {
annotationApiRef?.current?.setAnnotationStyle?.(activeTool, buildToolOptions(activeTool));
annotationApiRef?.current?.activateAnnotationTool?.(null as any);
setTimeout(() => {
annotationApiRef?.current?.activateAnnotationTool?.(activeTool, buildToolOptions(activeTool));
}, 50);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [activeTool, buildToolOptions, signatureApiRef]);
const deriveToolFromAnnotation = useCallback((annotation: any): AnnotationToolId | undefined => {
if (!annotation) return undefined;
const customToolId = annotation.customData?.toolId || annotation.customData?.annotationToolId;
if (isKnownAnnotationTool(customToolId)) {
return customToolId;
}
const type = annotation.type ?? annotation.object?.type;
switch (type) {
case 3: return 'text'; // FREETEXT
case 4: return 'line'; // LINE
case 5: return 'square'; // SQUARE
case 6: return 'circle'; // CIRCLE
case 7: return 'polygon'; // POLYGON
case 8: return 'polyline'; // POLYLINE
case 9: return 'highlight'; // HIGHLIGHT
case 10: return 'underline'; // UNDERLINE
case 11: return 'squiggly'; // SQUIGGLY
case 12: return 'strikeout'; // STRIKEOUT
case 13: return 'stamp'; // STAMP
case 15: return 'ink'; // INK
default: return undefined;
}
}, []);
const {
selectedAnn,
setSelectedAnn,
setSelectedAnnId,
} = useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
setInkWidth,
setShapeThickness,
setTextColor,
setTextBackgroundColor,
setNoteBackgroundColor,
setInkColor,
setHighlightColor,
setHighlightOpacity,
setFreehandHighlighterWidth,
setUnderlineColor,
setUnderlineOpacity,
setStrikeoutColor,
setStrikeoutOpacity,
setSquigglyColor,
setSquigglyOpacity,
setShapeStrokeColor,
setShapeFillColor,
setShapeOpacity,
setShapeStrokeOpacity,
setShapeFillOpacity,
setTextAlignment,
});
const steps =
selectedFiles.length === 0
? []
: [
{
title: t('annotation.title', 'Annotate'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<AnnotationPanel
activeTool={activeTool}
activateAnnotationTool={activateAnnotationTool}
styleState={styleState}
styleActions={styleActions}
getActiveColor={getActiveColor}
buildToolOptions={buildToolOptions}
deriveToolFromAnnotation={deriveToolFromAnnotation}
selectedAnn={selectedAnn}
selectedTextDraft={selectedTextDraft}
setSelectedTextDraft={setSelectedTextDraft}
selectedFontSize={selectedFontSize}
setSelectedFontSize={setSelectedFontSize}
annotationApiRef={annotationApiRef}
signatureApiRef={signatureApiRef}
viewerContext={viewerContext}
setPlacementMode={setPlacementMode}
setSignatureConfig={setSignatureConfig}
computeStampDisplaySize={computeStampDisplaySize}
stampImageData={stampImageData}
setStampImageData={setStampImageData}
stampImageSize={stampImageSize}
setStampImageSize={setStampImageSize}
setPlacementPreviewSize={setPlacementPreviewSize}
undo={undo}
redo={redo}
historyAvailability={historyAvailability}
onApplyChanges={handleApplyChanges}
applyDisabled={!hasUnsavedChanges}
/>
),
},
];
return createToolFlow({
files: {
selectedFiles,
isCollapsed: false,
},
steps,
review: {
isVisible: false,
operation: {
files: [],
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl: null,
downloadFilename: '',
isLoading: false,
status: '',
errorMessage: null,
progress: null,
executeOperation: async () => {},
resetResults: () => {},
clearError: () => {},
cancelOperation: () => {},
undoOperation: async () => {},
},
title: '',
onFileClick: () => {},
onUndo: () => {},
},
forceStepNumbers: true,
});
};
export default Annotate;

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