mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ea7d0cbc | ||
|
|
2c747e70c7 | ||
|
|
cb22c4c651 | ||
|
|
f723aa8abe | ||
|
|
1beed8dc57 | ||
|
|
63550e0c7d | ||
|
|
a2091b7851 | ||
|
|
43137cde83 | ||
|
|
9ea2c94912 | ||
|
|
ad33679c2b | ||
|
|
c390f21114 | ||
|
|
b03ecef121 | ||
|
|
3d9d2621fa | ||
|
|
9c84e1eed4 | ||
|
|
e424af1a31 | ||
|
|
71d416ce90 | ||
|
|
91cdd20b3f | ||
|
|
235e68fa2b | ||
|
|
3279b28a76 | ||
|
|
d95abcea95 | ||
|
|
a73636a597 |
@@ -43,6 +43,8 @@ app/core/src/main/resources/static/og_images/
|
||||
app/core/src/main/resources/static/samples/
|
||||
app/core/src/main/resources/static/manifest-classic.json
|
||||
app/core/src/main/resources/static/robots.txt
|
||||
app/core/src/main/resources/static/pdfium/
|
||||
app/core/src/main/resources/static/vendor/
|
||||
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
|
||||
|
||||
# Gradle
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
|
||||
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Path tempDirectory;
|
||||
|
||||
public MobileScannerService() throws IOException {
|
||||
// Create temp directory for mobile scanner uploads
|
||||
this.tempDirectory =
|
||||
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
|
||||
Files.createDirectories(tempDirectory);
|
||||
log.info("Mobile scanner temp directory: {}", tempDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores uploaded files for a session
|
||||
*
|
||||
* @param sessionId Unique session identifier
|
||||
* @param files Files to upload
|
||||
* @throws IOException If file storage fails
|
||||
*/
|
||||
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
|
||||
validateSessionId(sessionId);
|
||||
|
||||
SessionData session =
|
||||
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
|
||||
|
||||
// Create session directory
|
||||
Path sessionDir = tempDirectory.resolve(sessionId);
|
||||
Files.createDirectories(sessionDir);
|
||||
|
||||
// Save each file
|
||||
for (MultipartFile file : files) {
|
||||
if (file.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null || originalFilename.isBlank()) {
|
||||
originalFilename = "upload-" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Sanitize filename
|
||||
String safeFilename = sanitizeFilename(originalFilename);
|
||||
Path filePath = sessionDir.resolve(safeFilename);
|
||||
|
||||
// Handle duplicate filenames
|
||||
int counter = 1;
|
||||
while (Files.exists(filePath)) {
|
||||
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
: "";
|
||||
safeFilename = nameWithoutExt + "-" + counter + ext;
|
||||
filePath = sessionDir.resolve(safeFilename);
|
||||
counter++;
|
||||
}
|
||||
|
||||
file.transferTo(filePath);
|
||||
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
|
||||
log.info(
|
||||
"Uploaded file for session {}: {} ({} bytes)",
|
||||
sessionId,
|
||||
safeFilename,
|
||||
file.getSize());
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves file metadata for a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata, or empty list if session doesn't exist
|
||||
*/
|
||||
public List<FileMetadata> getSessionFiles(String sessionId) {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
return List.of();
|
||||
}
|
||||
session.updateLastAccess();
|
||||
return new ArrayList<>(session.getFiles());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves actual file data for download
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to retrieve
|
||||
* @return File path
|
||||
* @throws IOException If file not found or session doesn't exist
|
||||
*/
|
||||
public Path getFile(String sessionId, String filename) throws IOException {
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session == null) {
|
||||
throw new IOException("Session not found: " + sessionId);
|
||||
}
|
||||
|
||||
Path filePath = tempDirectory.resolve(sessionId).resolve(filename);
|
||||
if (!Files.exists(filePath)) {
|
||||
throw new IOException("File not found: " + filename);
|
||||
}
|
||||
|
||||
session.updateLastAccess();
|
||||
session.markFileAsDownloaded(filename);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a file after it has been served to the client. Should be called after successful
|
||||
* download.
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to delete
|
||||
*/
|
||||
public void deleteFileAfterDownload(String sessionId, String filename) {
|
||||
try {
|
||||
Path filePath = tempDirectory.resolve(sessionId).resolve(filename);
|
||||
Files.deleteIfExists(filePath);
|
||||
log.info("Deleted file after download: {}/{}", sessionId, filename);
|
||||
|
||||
// Check if all files have been downloaded - if so, delete the entire session
|
||||
SessionData session = activeSessions.get(sessionId);
|
||||
if (session != null && session.allFilesDownloaded()) {
|
||||
deleteSession(sessionId);
|
||||
log.info("All files downloaded - deleted session: {}", sessionId);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a session and all its files
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
*/
|
||||
public void deleteSession(String sessionId) {
|
||||
SessionData session = activeSessions.remove(sessionId);
|
||||
if (session != null) {
|
||||
try {
|
||||
Path sessionDir = tempDirectory.resolve(sessionId);
|
||||
if (Files.exists(sessionDir)) {
|
||||
// Delete all files in session directory
|
||||
Files.walk(sessionDir)
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
-a.compareTo(b)) // Reverse order to delete files before
|
||||
// directory
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete file: {}", path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
log.info("Deleted session: {}", sessionId);
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting session directory: {}", sessionId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
|
||||
@Scheduled(fixedRate = 5 * 60 * 1000)
|
||||
public void cleanupExpiredSessions() {
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> expiredSessions = new ArrayList<>();
|
||||
|
||||
activeSessions.forEach(
|
||||
(sessionId, session) -> {
|
||||
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
|
||||
expiredSessions.add(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!expiredSessions.isEmpty()) {
|
||||
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
|
||||
expiredSessions.forEach(this::deleteSession);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateSessionId(String sessionId) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new IllegalArgumentException("Session ID cannot be empty");
|
||||
}
|
||||
// Basic validation: alphanumeric and hyphens only
|
||||
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
|
||||
throw new IllegalArgumentException("Invalid session ID format");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
return filename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
}
|
||||
|
||||
/** File metadata for client */
|
||||
public static class FileMetadata {
|
||||
private final String filename;
|
||||
private final long size;
|
||||
private final String contentType;
|
||||
|
||||
public FileMetadata(String filename, long size, String contentType) {
|
||||
this.filename = filename;
|
||||
this.size = size;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
public String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
}
|
||||
|
||||
/** Session data tracking */
|
||||
private static class SessionData {
|
||||
private final String sessionId;
|
||||
private final List<FileMetadata> files = new ArrayList<>();
|
||||
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
|
||||
private final long createdAt;
|
||||
private long lastAccessTime;
|
||||
|
||||
public SessionData(String sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.createdAt = System.currentTimeMillis();
|
||||
this.lastAccessTime = createdAt;
|
||||
}
|
||||
|
||||
public void addFile(FileMetadata file) {
|
||||
files.add(file);
|
||||
downloadedFiles.put(file.getFilename(), false);
|
||||
}
|
||||
|
||||
public List<FileMetadata> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
public void markFileAsDownloaded(String filename) {
|
||||
downloadedFiles.put(filename, true);
|
||||
}
|
||||
|
||||
public boolean allFilesDownloaded() {
|
||||
return !downloadedFiles.isEmpty()
|
||||
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
|
||||
}
|
||||
|
||||
public void updateLastAccess() {
|
||||
this.lastAccessTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public long getLastAccessTime() {
|
||||
return lastAccessTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ public class RequestUriUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
|
||||
if (normalizedUri.startsWith("/mobile-scanner")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Treat common static file extensions as static resources
|
||||
return normalizedUri.endsWith(".svg")
|
||||
|| normalizedUri.endsWith(".png")
|
||||
@@ -168,6 +173,8 @@ public class RequestUriUtils {
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/v1/api-docs");
|
||||
}
|
||||
|
||||
|
||||
+3
-15
@@ -268,18 +268,6 @@ tasks.register('cleanFrontendAssets', Delete) {
|
||||
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
|
||||
tasks.named('copyFrontendAssets').configure {
|
||||
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
||||
@@ -289,9 +277,9 @@ if (buildWithFrontend) {
|
||||
println "Frontend build enabled - JAR will include React frontend"
|
||||
processResources.dependsOn copyFrontendAssets
|
||||
} else {
|
||||
println "Frontend build disabled - JAR will be backend-only with API landing page"
|
||||
// When not building the UI, ensure any stale frontend assets are removed and use API landing page
|
||||
processResources.dependsOn copyApiLandingPage
|
||||
println "Frontend build disabled - JAR will be backend-only"
|
||||
// When not building the UI, ensure any stale frontend assets are removed
|
||||
processResources.dependsOn cleanFrontendAssets
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
|
||||
@@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"principal",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"async");
|
||||
"async",
|
||||
"session");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
||||
@@ -33,35 +33,14 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// Cache hashed assets (JS/CSS with content hashes) for 1 year
|
||||
// These files have names like index-ChAS4tCC.js that change when content changes
|
||||
// Check customFiles/static first, then fall back to classpath
|
||||
registry.addResourceHandler("/assets/**")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath()
|
||||
+ "assets/",
|
||||
"classpath:/static/assets/")
|
||||
.addResourceLocations("classpath:/static/assets/")
|
||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
|
||||
|
||||
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
|
||||
// Note: index.html is handled by ReactRoutingController for dynamic processing
|
||||
registry.addResourceHandler("/index.html")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
|
||||
// Handle all other static resources (js, css, images, fonts, etc.)
|
||||
// Check customFiles/static first for user overrides
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+4
@@ -71,6 +71,10 @@ public class ConfigController {
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
// Add frontendUrl for mobile scanner QR codes
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.MobileScannerService;
|
||||
import stirling.software.common.service.MobileScannerService.FileMetadata;
|
||||
|
||||
/**
|
||||
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
|
||||
* that can be retrieved by desktop clients via a session-based system. No authentication required
|
||||
* for peer-to-peer scanning workflow.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/mobile-scanner")
|
||||
@Tag(
|
||||
name = "Mobile Scanner",
|
||||
description =
|
||||
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
|
||||
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class MobileScannerController {
|
||||
|
||||
private final MobileScannerService mobileScannerService;
|
||||
|
||||
public MobileScannerController(MobileScannerService mobileScannerService) {
|
||||
this.mobileScannerService = mobileScannerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files from mobile device
|
||||
*
|
||||
* @param sessionId Unique session identifier from QR code
|
||||
* @param files Files to upload
|
||||
* @return Upload status
|
||||
*/
|
||||
@PostMapping("/upload/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Upload scanned files from mobile device",
|
||||
description = "Mobile devices upload scanned images to a temporary session")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "Files uploaded successfully",
|
||||
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
|
||||
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
|
||||
@ApiResponse(responseCode = "500", description = "Upload failed")
|
||||
public ResponseEntity<Map<String, Object>> uploadFiles(
|
||||
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
|
||||
String sessionId,
|
||||
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
|
||||
List<MultipartFile> files) {
|
||||
|
||||
try {
|
||||
if (files == null || files.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
|
||||
}
|
||||
|
||||
mobileScannerService.uploadFiles(sessionId, files);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("sessionId", sessionId);
|
||||
response.put("filesUploaded", files.size());
|
||||
response.put("message", "Files uploaded successfully");
|
||||
|
||||
log.info("Mobile scanner upload: session={}, files={}", sessionId, files.size());
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to upload files for session: {}", sessionId, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to save files"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of uploaded files for a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @return List of file metadata
|
||||
*/
|
||||
@GetMapping("/files/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Get uploaded files for a session",
|
||||
description = "Desktop clients poll this endpoint to check for new uploads")
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "File list retrieved",
|
||||
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
|
||||
public ResponseEntity<Map<String, Object>> getSessionFiles(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("sessionId", sessionId);
|
||||
response.put("files", files);
|
||||
response.put("count", files.size());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific file from a session
|
||||
*
|
||||
* @param sessionId Session identifier
|
||||
* @param filename Filename to download
|
||||
* @return File content
|
||||
*/
|
||||
@GetMapping("/download/{sessionId}/{filename}")
|
||||
@Operation(
|
||||
summary = "Download a specific file",
|
||||
description =
|
||||
"Download a file that was uploaded to a session. File is automatically deleted after download.")
|
||||
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
|
||||
@ApiResponse(responseCode = "404", description = "File or session not found")
|
||||
public ResponseEntity<Resource> downloadFile(
|
||||
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
|
||||
@Parameter(description = "Filename to download", required = true) @PathVariable
|
||||
String filename) {
|
||||
|
||||
try {
|
||||
Path filePath = mobileScannerService.getFile(sessionId, filename);
|
||||
|
||||
// Read file into memory first, so we can delete it before sending
|
||||
byte[] fileBytes = Files.readAllBytes(filePath);
|
||||
|
||||
String contentType = Files.probeContentType(filePath);
|
||||
if (contentType == null) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
|
||||
// Delete file immediately after reading into memory (server-side cleanup)
|
||||
mobileScannerService.deleteFileAfterDownload(sessionId, filename);
|
||||
|
||||
// Serve from memory
|
||||
Resource resource = new org.springframework.core.io.ByteArrayResource(fileBytes);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"")
|
||||
.body(resource);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("File not found: session={}, file={}", sessionId, filename);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session and all its files
|
||||
*
|
||||
* @param sessionId Session to delete
|
||||
* @return Deletion status
|
||||
*/
|
||||
@DeleteMapping("/session/{sessionId}")
|
||||
@Operation(
|
||||
summary = "Delete a session",
|
||||
description = "Manually delete a session and all its uploaded files")
|
||||
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
|
||||
public ResponseEntity<Map<String, Object>> deleteSession(
|
||||
@Parameter(description = "Session ID to delete", required = true) @PathVariable
|
||||
String sessionId) {
|
||||
|
||||
mobileScannerService.deleteSession(sessionId);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of("success", true, "sessionId", sessionId, "message", "Session deleted"));
|
||||
}
|
||||
|
||||
// Response schemas for OpenAPI documentation
|
||||
private static class UploadResponse {
|
||||
public boolean success;
|
||||
public String sessionId;
|
||||
public int filesUploaded;
|
||||
public String message;
|
||||
}
|
||||
|
||||
private static class FileListResponse {
|
||||
public String sessionId;
|
||||
public List<FileMetadata> files;
|
||||
public int count;
|
||||
}
|
||||
}
|
||||
+4
-45
@@ -3,14 +3,9 @@ package stirling.software.SPDF.controller.web;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
@@ -19,11 +14,6 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Slf4j
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@@ -32,44 +22,24 @@ public class ReactRoutingController {
|
||||
|
||||
private String cachedIndexHtml;
|
||||
private boolean indexHtmlExists = false;
|
||||
private boolean useExternalIndexHtml = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
log.debug("Checking for custom index.html at: {}", externalIndexPath);
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
log.info("Using custom index.html from: {}", externalIndexPath);
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = true;
|
||||
return;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load custom index.html, falling back to classpath", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to classpath index.html
|
||||
// Only cache if index.html exists (production builds)
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
if (resource.exists()) {
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
this.useExternalIndexHtml = false;
|
||||
} catch (IOException e) {
|
||||
// Failed to cache, will process on each request
|
||||
log.warn("Failed to cache index.html", e);
|
||||
this.indexHtmlExists = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String processIndexHtml() throws IOException {
|
||||
Resource resource = getIndexHtmlResource();
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
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(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
@@ -115,13 +74,13 @@ public class ReactRoutingController {
|
||||
}
|
||||
|
||||
@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|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
|
||||
@@ -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,53 +1,99 @@
|
||||
/* Light theme variables */
|
||||
:root {
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
|
||||
--cc-btn-primary-bg: #007BFF;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007BFF;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007BFF;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007BFF;
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
--cc-toggle-on-bg: #007BFF;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747E;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme variables */
|
||||
.cc--darkmode{
|
||||
--cc-bg: var(--md-sys-color-inverse-on-surface);
|
||||
--cc-primary-color: var(--md-sys-color-on-surface);
|
||||
--cc-secondary-color: var(--md-sys-color-on-surface);
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: var(--md-sys-color-secondary);
|
||||
--cc-btn-primary-color: var(--cc-bg);
|
||||
--cc-btn-primary-border-color: var(--cc-btn-primary-bg);
|
||||
--cc-btn-primary-hover-bg: var(--md-sys-color-surface-3);
|
||||
--cc-btn-primary-hover-color: var(--md-sys-color-on-secondary-container);
|
||||
--cc-btn-primary-hover-border-color: var(--md-sys-color-surface-3);
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: var(--md-sys-color-surface-3);
|
||||
--cc-btn-secondary-color: var(--md-sys-color-on-secondary-container);
|
||||
--cc-btn-secondary-border-color: var(--md-sys-color-surface-3);
|
||||
--cc-btn-secondary-hover-bg:var(--md-sys-color-secondary);
|
||||
--cc-btn-secondary-hover-color: var(--cc-bg);
|
||||
--cc-btn-secondary-hover-border-color: var(--md-sys-color-secondary);
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: var(--md-sys-color-outline);
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
--cc-toggle-on-bg: var(--cc-btn-primary-bg);
|
||||
--cc-toggle-off-bg: var(--md-sys-color-outline);
|
||||
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
|
||||
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
|
||||
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
|
||||
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
|
||||
--cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg);
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
|
||||
--cc-section-category-border: var(--md-sys-color-outline);
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
|
||||
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
|
||||
|
||||
/* --cc-overlay-bg: rgba(0, 0, 0, 0.65);
|
||||
--cc-webkit-scrollbar-bg: var(--cc-section-category-border);
|
||||
--cc-webkit-scrollbar-hover-bg: var(--cc-btn-primary-hover-bg);
|
||||
*/
|
||||
--cc-footer-bg: var(--cc-bg);
|
||||
--cc-footer-color: var(--cc-primary-color);
|
||||
--cc-footer-border-color: var(--cc-bg);
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
@@ -78,7 +124,83 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle visibility fixes */
|
||||
#cc-main .section__toggle {
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-circle {
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Ensure toggles are visible in both themes */
|
||||
#cc-main .toggle__icon {
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
}
|
||||
|
||||
/* Ensure toggle text is visible */
|
||||
#cc-main .pm__section-title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .pm__section-desc {
|
||||
color: var(--cc-secondary-color) !important;
|
||||
}
|
||||
|
||||
/* Make sure the modal has proper contrast */
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
#cc-main .cm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__desc {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer-links a,
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"name": "Stirling-PDF",
|
||||
"short_name": "Stirling-PDF",
|
||||
"short_name": "Stirling PDF",
|
||||
"name": "Stirling PDF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"src": "modern-logo/favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"src": "modern-logo/logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "modern-logo/logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#000000"
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.1.5'
|
||||
version = '2.1.4'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -19,5 +19,8 @@
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="src/index.tsx"></script>
|
||||
<!-- jscanify and OpenCV for mobile scanner - loaded after React for non-blocking page load -->
|
||||
<script src="/vendor/jscanify/opencv.js" async></script>
|
||||
<script src="/vendor/jscanify/jscanify.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1151
-2563
File diff suppressed because it is too large
Load Diff
@@ -54,7 +54,9 @@
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
|
||||
@@ -231,6 +231,26 @@ failed = "Add page numbers operation failed"
|
||||
[addPageNumbers.results]
|
||||
title = "Page Number Results"
|
||||
|
||||
[addPageNumbers.help]
|
||||
title = "Page Numbers & Bates Numbering Help"
|
||||
variables = "Variable Substitution"
|
||||
variablesDesc = "Use these variables in the Custom Text Format field to create dynamic page numbering:"
|
||||
variableN = "{n} - Current page number"
|
||||
variableTotal = "{total} - Total number of pages"
|
||||
variableFilename = "{filename} - Document filename (without extension)"
|
||||
examples = "Common Examples"
|
||||
exampleSimple = "Simple numbering: {n} → 1, 2, 3, 4..."
|
||||
examplePageOf = "Page X of Y: Page {n} of {total} → Page 1 of 10, Page 2 of 10..."
|
||||
exampleBates = "Legal Bates numbering: ABC-{n} → ABC-001, ABC-002, ABC-003..."
|
||||
exampleDocument = "Document reference: {filename}-{n} → mydoc-001, mydoc-002..."
|
||||
exampleCustom = "Custom format: Doc {filename} | Page {n}/{total}"
|
||||
positioning = "Positioning"
|
||||
positioningDesc = "Use the 1-9 grid system to quickly position page numbers, or use the margin size to adjust distance from edges."
|
||||
tips = "Tips"
|
||||
tip1 = "Starting Number: Change the starting number to begin counting from any value (useful for multi-part documents)"
|
||||
tip2 = "Page Selection: Number only specific pages by entering ranges like 1,3,5-8"
|
||||
tip3 = "Formatting: Choose font type, size, and colour to match your document style"
|
||||
|
||||
[app]
|
||||
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
|
||||
@@ -657,24 +677,24 @@ title = "PDF Multi Tool"
|
||||
desc = "Merge, Rotate, Rearrange, Split, and Remove pages"
|
||||
|
||||
[home.merge]
|
||||
tags = "combine,join,unite"
|
||||
title = "Merge"
|
||||
desc = "Easily merge multiple PDFs into one."
|
||||
tags = "combine,join,unite,merge,collate,append,concatenate,put together,bind,join files,multiple PDFs into one,PDF merge"
|
||||
title = "Merge / Combine PDFs"
|
||||
desc = "Easily merge multiple PDFs into one document"
|
||||
|
||||
[home.split]
|
||||
tags = "divide,separate,break"
|
||||
tags = "divide,separate,break,burst,extract pages,split by size,split by chapters,partition,break into files,disassemble,PDF split"
|
||||
title = "Split"
|
||||
desc = "Split PDFs into multiple documents"
|
||||
desc = "Split PDFs by page numbers, file size, page count, chapters, or sections. Multiple splitting methods available."
|
||||
|
||||
[home.rotate]
|
||||
tags = "turn,flip,orient"
|
||||
tags = "turn,flip,orient,rotate,spin,turn pages,flip orientation,rotate PDF,clockwise,counterclockwise"
|
||||
title = "Rotate"
|
||||
desc = "Easily rotate your PDFs."
|
||||
desc = "Easily rotate your PDFs clockwise or counterclockwise"
|
||||
|
||||
[home.convert]
|
||||
tags = "transform,change"
|
||||
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to JPG,Word to PDF,export,save as,format conversion,DOCX,XLSX,PPT,image to PDF"
|
||||
title = "Convert"
|
||||
desc = "Convert files between different formats"
|
||||
desc = "Convert between PDF and other formats: Word (DOCX), Excel (XLSX), PowerPoint (PPTX), Images (JPG/PNG), HTML, and more"
|
||||
|
||||
[home.pdfOrganiser]
|
||||
tags = "organize,rearrange,reorder"
|
||||
@@ -682,164 +702,159 @@ title = "Organise"
|
||||
desc = "Remove/Rearrange pages in any order"
|
||||
|
||||
[home.addImage]
|
||||
tags = "insert,embed,place"
|
||||
title = "Add image"
|
||||
desc = "Adds a image onto a set location on the PDF"
|
||||
tags = "insert,embed,place,add image,insert image,add logo,add photo,place image,insert graphic,embed image"
|
||||
title = "Insert Image into PDF"
|
||||
desc = "Add images to PDF at specific locations. Insert logos, signatures, diagrams, or photos."
|
||||
|
||||
[home.addAttachments]
|
||||
tags = "embed,attach,include"
|
||||
title = "Add Attachments"
|
||||
desc = "Add or remove embedded files (attachments) to/from a PDF"
|
||||
tags = "embed,attach,include,add files,embed files,attach documents,extract attachments,embedded files,PDF portfolio"
|
||||
title = "Add/Remove Embedded Files"
|
||||
desc = "Attach files to PDF (embed documents, spreadsheets, images) or extract existing attachments"
|
||||
|
||||
[home.watermark]
|
||||
tags = "stamp,mark,overlay"
|
||||
tags = "stamp,mark,overlay,watermark,draft,confidential,copyright,brand,security watermark,tiled watermark,repeating pattern"
|
||||
title = "Add Watermark"
|
||||
desc = "Add a custom watermark to your PDF document."
|
||||
desc = "Add text or image watermarks. Create tiled patterns or single placement. Flatten for security."
|
||||
|
||||
[home.removePassword]
|
||||
tags = "unlock"
|
||||
title = "Remove Password"
|
||||
desc = "Remove password protection from your PDF document."
|
||||
tags = "unlock,decrypt,remove protection,remove security,unlock PDF,password removal,remove password"
|
||||
title = "Unlock PDF / Remove Password"
|
||||
desc = "Remove password protection from your PDF document"
|
||||
|
||||
[home.compress]
|
||||
tags = "shrink,reduce,optimize"
|
||||
tags = "shrink,reduce,optimize,reduce file size,make smaller,downsize,compress for email,optimize for web,downsample,reduce quality"
|
||||
title = "Compress"
|
||||
desc = "Compress PDFs to reduce their file size."
|
||||
desc = "Reduce PDF file size by compressing images and optimizing content. Choose quality level or target file size."
|
||||
|
||||
[home.unlockPDFForms]
|
||||
tags = "unlock,enable,edit"
|
||||
title = "Unlock PDF Forms"
|
||||
desc = "Remove read-only property of form fields in a PDF document."
|
||||
tags = "unlock,enable,edit,unlock forms,make editable,remove read-only,enable form fields,editable forms"
|
||||
title = "Unlock Form Fields"
|
||||
desc = "Remove read-only restrictions from PDF form fields. Enable editing of locked forms."
|
||||
|
||||
[home.changeMetadata]
|
||||
tags = "edit,modify,update"
|
||||
title = "Change Metadata"
|
||||
desc = "Change/Remove/Add metadata from a PDF document"
|
||||
tags = "edit,modify,update,metadata,document properties,file info,XMP,Dublin Core,author,title,subject,keywords"
|
||||
title = "Edit Metadata / Properties"
|
||||
desc = "Change title, author, subject, keywords, and other document properties (XMP metadata)"
|
||||
|
||||
[home.ocr]
|
||||
tags = "extract,scan"
|
||||
title = "OCR / Cleanup scans"
|
||||
desc = "Cleanup scans and detects text from images within a PDF and re-adds it as text."
|
||||
tags = "extract,scan,OCR,make searchable,text recognition,searchable PDF,extract text,scan to text,recognize text,optical character recognition"
|
||||
title = "OCR - Make PDF Searchable"
|
||||
desc = "Extract text from scanned images using Optical Character Recognition. Makes PDFs searchable and editable."
|
||||
|
||||
[home.extractImages]
|
||||
tags = "pull,save,export"
|
||||
tags = "pull,save,export,extract images,get images,save images,export graphics,image extraction,pictures from PDF,save pictures"
|
||||
title = "Extract Images"
|
||||
desc = "Extracts all images from a PDF and saves them to zip"
|
||||
desc = "Extract all images from PDF and save them as separate files in ZIP archive"
|
||||
|
||||
[home.scannerImageSplit]
|
||||
tags = "detect,split,photos"
|
||||
title = "Detect & Split Scanned Photos"
|
||||
desc = "Detect and split scanned photos into separate pages"
|
||||
tags = "detect,split,photos,extract photos,scan separation,photo detection,scanner image split,separate scanned photos,photo extraction"
|
||||
title = "Extract Photos from Scan"
|
||||
desc = "Automatically detect and separate individual photos from scanned pages. Advanced OpenCV-based detection."
|
||||
|
||||
[home.sign]
|
||||
tags = "signature,autograph"
|
||||
title = "Sign"
|
||||
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"
|
||||
tags = "signature,autograph,e-sign,electronic signature,fill and sign,sign PDF online,add signature,e-signature"
|
||||
title = "E-Sign / Fill & Sign"
|
||||
desc = "Add electronic signature by drawing, typing, or uploading image. For legal digital signatures, use Certificate Sign tool."
|
||||
|
||||
[home.flatten]
|
||||
tags = "simplify,remove,interactive"
|
||||
title = "Flatten"
|
||||
desc = "Remove all interactive elements and forms from a PDF"
|
||||
tags = "simplify,remove,interactive,flatten,rasterize,make non-editable,burn in,lock form,static PDF,remove form fields"
|
||||
title = "Flatten PDF"
|
||||
desc = "Convert interactive elements to static content. Removes form editability, burns in annotations, and locks content."
|
||||
|
||||
[home.certSign]
|
||||
tags = "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto"
|
||||
title = "Sign with Certificate"
|
||||
desc = "Signs a PDF with a Certificate/Key (PEM/P12)"
|
||||
tags = "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto,digital signature,PKI signature,cryptographic signature,certificate-based"
|
||||
title = "Digital Signature (Certificate)"
|
||||
desc = "Add cryptographic digital signature using certificate (PEM/P12/PKCS12). More secure than basic e-signature."
|
||||
|
||||
[home.repair]
|
||||
tags = "fix,restore"
|
||||
tags = "fix,restore,repair,corrupted PDF,damaged PDF,broken PDF,recover,fix errors,repair corrupted"
|
||||
title = "Repair"
|
||||
desc = "Tries to repair a corrupt/broken PDF"
|
||||
desc = "Attempt to repair corrupted or damaged PDF files. Recovers readable content when possible."
|
||||
|
||||
[home.removeBlanks]
|
||||
tags = "delete,clean,empty"
|
||||
title = "Remove Blank pages"
|
||||
desc = "Detects and removes blank pages from a document"
|
||||
tags = "delete,clean,empty,remove blank pages,remove empty pages,delete blank,clean empty,auto-remove blanks"
|
||||
title = "Remove Blank/Empty Pages"
|
||||
desc = "Automatically detect and remove blank pages from document"
|
||||
|
||||
[home.removeAnnotations]
|
||||
tags = "delete,clean,strip"
|
||||
title = "Remove Annotations"
|
||||
desc = "Removes all comments/annotations from a PDF"
|
||||
tags = "delete,clean,strip,remove annotations,remove comments,delete markup,strip comments,clean annotations"
|
||||
title = "Remove Comments & Annotations"
|
||||
desc = "Delete all comments, highlights, notes, and markup from PDF. Clean document for final version."
|
||||
|
||||
[home.compare]
|
||||
tags = "difference"
|
||||
tags = "difference,compare,redline,track changes,document comparison,find differences,version comparison,diff"
|
||||
title = "Compare"
|
||||
desc = "Compares and shows the differences between 2 PDF Documents"
|
||||
desc = "Compare two PDF versions and highlight differences. Shows added, deleted, and modified content."
|
||||
|
||||
[home.removeCertSign]
|
||||
tags = "remove,delete,unlock"
|
||||
tags = "remove,delete,unlock,remove signature,remove certificate,unsign,remove digital signature"
|
||||
title = "Remove Certificate Sign"
|
||||
desc = "Remove certificate signature from PDF"
|
||||
desc = "Remove certificate-based digital signatures from PDF. Note: May invalidate document authenticity."
|
||||
|
||||
[home.pageLayout]
|
||||
tags = "layout,arrange,combine"
|
||||
title = "Multi-Page Layout"
|
||||
desc = "Merge multiple pages of a PDF document into a single page"
|
||||
tags = "layout,arrange,combine,N-up,2-up,4-up,multiple pages per sheet,pages per sheet,grid layout,print multiple"
|
||||
title = "Multi-Page Layout (N-Up)"
|
||||
desc = "Combine multiple pages into single sheet. Create 2-up, 4-up, 9-up layouts for printing or viewing."
|
||||
|
||||
[home.bookletImposition]
|
||||
tags = "booklet,print,binding"
|
||||
title = "Booklet Imposition"
|
||||
desc = "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
tags = "booklet,print,binding,imposition,print booklet,duplex,gutter,saddle stitch,perfect binding,print layout"
|
||||
title = "Create Booklet / Imposition"
|
||||
desc = "Arrange pages for booklet printing with proper ordering. Supports duplex printing, gutter margins, and binding options."
|
||||
|
||||
[home.scalePages]
|
||||
tags = "resize,adjust,scale"
|
||||
title = "Adjust page size/scale"
|
||||
desc = "Change the size/scale of a page and/or its contents."
|
||||
tags = "resize,adjust,scale,page size,paper size,A4,Letter,Legal,scale content,change dimensions"
|
||||
title = "Resize Pages / Change Page Size"
|
||||
desc = "Change page dimensions or scale content. Convert between paper sizes (A4, Letter, Legal) or apply custom scaling."
|
||||
|
||||
[home.addPageNumbers]
|
||||
tags = "number,pagination,count"
|
||||
title = "Add Page Numbers"
|
||||
desc = "Add Page numbers throughout a document in a set location"
|
||||
tags = "number,pagination,count,bates,bates numbering,sequential,legal numbering,document numbering,header,footer,page numbers"
|
||||
title = "Add Page Numbers / Bates"
|
||||
desc = "Add page numbers or Bates-style sequential numbering with custom formatting (ABC-001, Page {n} of {total}). Use {n} for page number, {total} for total pages, {filename} for document name."
|
||||
|
||||
[home.autoRename]
|
||||
tags = "auto-detect,header-based,organize,relabel"
|
||||
title = "Auto Rename PDF File"
|
||||
desc = "Auto renames a PDF file based on its detected header"
|
||||
tags = "auto-detect,header-based,organize,relabel,auto rename,smart rename,detect title,organize files"
|
||||
title = "Auto Rename by Header"
|
||||
desc = "Automatically rename PDF files based on detected header or title text. Organize documents intelligently."
|
||||
|
||||
[home.adjustContrast]
|
||||
tags = "contrast,brightness,saturation"
|
||||
title = "Adjust Colours/Contrast"
|
||||
desc = "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
tags = "contrast,brightness,saturation,adjust colors,enhance,improve readability,image enhancement,scan enhancement,color adjustment"
|
||||
title = "Adjust Colors & Contrast"
|
||||
desc = "Enhance PDF appearance by adjusting colors, contrast, saturation, and brightness. Improve readability of scanned documents."
|
||||
|
||||
[home.crop]
|
||||
tags = "trim,cut,resize"
|
||||
tags = "trim,cut,resize,crop,remove margins,crop margins,trim whitespace,adjust margins"
|
||||
title = "Crop PDF"
|
||||
desc = "Crop a PDF to reduce its size (maintains text!)"
|
||||
desc = "Crop PDF pages to remove margins or unwanted areas. Maintains text and quality (not image-based)."
|
||||
|
||||
[home.autoSplitPDF]
|
||||
tags = "auto,split,QR"
|
||||
title = "Auto Split Pages"
|
||||
desc = "Auto Split Scanned PDF with physical scanned page splitter QR Code"
|
||||
tags = "auto,split,QR,QR code,divider,batch scanning,auto split,automatic splitting,duplex scan"
|
||||
title = "Auto Split by QR Code"
|
||||
desc = "Automatically split scanned PDFs using QR code divider pages. Supports duplex scanning mode."
|
||||
|
||||
[home.sanitize]
|
||||
tags = "clean,purge,remove"
|
||||
tags = "clean,purge,remove,sanitize,remove hidden data,privacy,GDPR,remove metadata,clean document,security,strip data"
|
||||
title = "Sanitise"
|
||||
desc = "Remove potentially harmful elements from PDF files"
|
||||
desc = "Remove potentially harmful elements and hidden data from PDFs. Cleans metadata, scripts, and embedded content for privacy."
|
||||
|
||||
[home.getPdfInfo]
|
||||
tags = "info,metadata,details"
|
||||
tags = "info,metadata,details,document properties,file information,PDF info,view properties,document details"
|
||||
title = "Get ALL Info on PDF"
|
||||
desc = "Grabs any and all information possible on PDFs"
|
||||
desc = "View comprehensive information about PDFs including metadata, properties, fonts, images, and structure"
|
||||
|
||||
[home.pdfToSinglePage]
|
||||
tags = "combine,merge,single"
|
||||
title = "PDF to Single Large Page"
|
||||
desc = "Merges all PDF pages into one large single page"
|
||||
tags = "combine,merge,single,single page,continuous page,vertical scroll,one page,poster,long page"
|
||||
title = "Merge to Single Continuous Page"
|
||||
desc = "Combine all pages into one large continuous page. Useful for vertical scrolling or poster creation."
|
||||
|
||||
[home.showJS]
|
||||
tags = "javascript,code,script"
|
||||
title = "Show Javascript"
|
||||
desc = "Searches and displays any JS injected into a PDF"
|
||||
tags = "javascript,code,script,view javascript,inspect code,security check,embedded scripts,PDF security"
|
||||
title = "View Embedded JavaScript"
|
||||
desc = "Display any JavaScript code embedded in PDF. Security tool to inspect potentially harmful scripts."
|
||||
|
||||
[home.redact]
|
||||
tags = "censor,blackout,hide"
|
||||
tags = "censor,blackout,hide,redact,remove sensitive information,permanent removal,privacy,GDPR,sanitize text,regex redaction,sensitive data"
|
||||
title = "Redact"
|
||||
desc = "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
desc = "Permanently remove sensitive content from PDFs. Supports text search, regex patterns, and manual selection. Cleans metadata."
|
||||
|
||||
[home.splitBySections]
|
||||
tags = "split,sections,divide"
|
||||
@@ -847,14 +862,14 @@ title = "Split PDF by Sections"
|
||||
desc = "Divide each page of a PDF into smaller horizontal and vertical sections"
|
||||
|
||||
[home.addStamp]
|
||||
tags = "stamp,mark,seal"
|
||||
tags = "stamp,mark,seal,approval,reviewed,approved,confidential,certified,notarized,endorsement,business stamp"
|
||||
title = "Add Stamp to PDF"
|
||||
desc = "Add text or add image stamps at set locations"
|
||||
desc = "Add approval stamps, custom text, or images with positioning control. Supports variables: {n}, {total}, {filename}. Multi-language support available."
|
||||
|
||||
[home.removeImage]
|
||||
tags = "remove,delete,clean"
|
||||
title = "Remove image"
|
||||
desc = "Remove image from PDF to reduce file size"
|
||||
tags = "remove,delete,clean,remove images,delete pictures,strip images,reduce size,remove graphics,delete photos"
|
||||
title = "Remove Images from PDF"
|
||||
desc = "Delete all images from PDF to reduce file size or remove sensitive visual content"
|
||||
|
||||
[home.splitByChapters]
|
||||
tags = "split,chapters,structure"
|
||||
@@ -862,9 +877,9 @@ title = "Split PDF by Chapters"
|
||||
desc = "Split a PDF into multiple files based on its chapter structure."
|
||||
|
||||
[home.validateSignature]
|
||||
tags = "validate,verify,certificate"
|
||||
title = "Validate PDF Signature"
|
||||
desc = "Verify digital signatures and certificates in PDF documents"
|
||||
tags = "validate,verify,certificate,check signature,authenticity,signature verification,certificate check,PKI,digital signature validation"
|
||||
title = "Verify Digital Signature"
|
||||
desc = "Validate digital signatures and verify certificate authenticity. Check document integrity and signer identity."
|
||||
|
||||
[home.swagger]
|
||||
tags = "API,documentation,test"
|
||||
@@ -877,9 +892,9 @@ title = "Scanner Effect"
|
||||
desc = "Create a PDF that looks like it was scanned"
|
||||
|
||||
[home.editTableOfContents]
|
||||
tags = "bookmarks,contents,edit"
|
||||
title = "Edit Table of Contents"
|
||||
desc = "Add or edit bookmarks and table of contents in PDF documents"
|
||||
tags = "bookmarks,contents,edit,table of contents,TOC,navigation,outline,document structure,edit bookmarks"
|
||||
title = "Edit Bookmarks / TOC"
|
||||
desc = "Add, edit, or remove PDF bookmarks (navigation outline). Create document structure for easy navigation."
|
||||
|
||||
[home.manageCertificates]
|
||||
tags = "certificates,import,export"
|
||||
@@ -887,24 +902,24 @@ title = "Manage Certificates"
|
||||
desc = "Import, export, or delete digital certificate files used for signing PDFs."
|
||||
|
||||
[home.read]
|
||||
tags = "view,open,display"
|
||||
title = "Read"
|
||||
desc = "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
|
||||
tags = "view,open,display,read,annotate,comment,highlight,markup,PDF viewer,collaboration,review"
|
||||
title = "View & Annotate PDF"
|
||||
desc = "View PDFs with annotation tools. Highlight text, draw, add comments, and collaborate on documents."
|
||||
|
||||
[home.reorganizePages]
|
||||
tags = "rearrange,reorder,organize"
|
||||
title = "Reorganize Pages"
|
||||
desc = "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
|
||||
tags = "rearrange,reorder,organize,organize pages,resequence,resort,change page order,move pages,drag and drop,PDF organize"
|
||||
title = "Organize Pages"
|
||||
desc = "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control"
|
||||
|
||||
[home.extractPages]
|
||||
tags = "pull,select,copy"
|
||||
tags = "pull,select,copy,extract pages,save pages,copy pages,pull pages,select pages,PDF extract"
|
||||
title = "Extract Pages"
|
||||
desc = "Extract specific pages from a PDF document"
|
||||
desc = "Extract specific pages from a PDF document and save them separately"
|
||||
|
||||
[home.removePages]
|
||||
tags = "delete,extract,exclude"
|
||||
tags = "delete,extract,exclude,remove pages,delete pages,take out pages,discard pages,PDF delete"
|
||||
title = "Remove Pages"
|
||||
desc = "Remove specific pages from a PDF document"
|
||||
desc = "Remove specific pages from a PDF document permanently"
|
||||
|
||||
[home.autoSizeSplitPDF]
|
||||
tags = "auto,split,size"
|
||||
@@ -912,8 +927,9 @@ title = "Auto Split by Size/Count"
|
||||
desc = "Automatically split PDFs by file size or page count"
|
||||
|
||||
[home.replaceColor]
|
||||
title = "Replace & Invert Colour"
|
||||
desc = "Replace or invert colours in PDF documents"
|
||||
tags = "replace color,invert,color swap,dark mode,invert colors,change colors,color transformation,accessibility"
|
||||
title = "Replace & Invert Colors"
|
||||
desc = "Swap colors in PDF or invert all colors. Useful for dark mode, accessibility, or color correction."
|
||||
|
||||
[home.devApi]
|
||||
tags = "API,development,documentation"
|
||||
@@ -934,12 +950,14 @@ title = "Air-gapped Setup"
|
||||
desc = "Link to air-gapped setup guide"
|
||||
|
||||
[home.addPassword]
|
||||
title = "Add Password"
|
||||
desc = "Add password protection and restrictions to PDF files"
|
||||
tags = "password,protect,encrypt,secure,lock,add password,security,user password,owner password,permissions,password protect"
|
||||
title = "Password Protect / Encrypt PDF"
|
||||
desc = "Add password protection to secure your PDF. Set user password (open document) or owner password (permissions)."
|
||||
|
||||
[home.changePermissions]
|
||||
title = "Change Permissions"
|
||||
desc = "Change document restrictions and permissions"
|
||||
tags = "permissions,restrictions,security,access control,disable printing,disable copying,owner password,restrict editing,PDF permissions"
|
||||
title = "Set Permissions / Restrictions"
|
||||
desc = "Control what users can do with your PDF: printing, copying text, editing, form filling, and more"
|
||||
|
||||
[home.automate]
|
||||
tags = "workflow,sequence,automation"
|
||||
@@ -947,22 +965,25 @@ title = "Automate"
|
||||
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
|
||||
[home.overlay-pdfs]
|
||||
desc = "Overlay one PDF on top of another"
|
||||
tags = "overlay,superimpose,combine,merge layers,background,letterhead,template,place over"
|
||||
title = "Overlay PDFs"
|
||||
desc = "Superimpose one PDF on top of another. Useful for adding letterheads, templates, or background designs."
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF Text Editor"
|
||||
desc = "Edit existing text and images inside PDFs"
|
||||
tags = "edit,text,modify,edit PDF,change text,edit content,PDF editor,text editing,modify text"
|
||||
title = "Edit PDF Text Directly"
|
||||
desc = "Edit text content directly in PDF. Modify, add, or delete text like a word processor."
|
||||
|
||||
[home.addText]
|
||||
tags = "text,annotation,label"
|
||||
tags = "text,annotation,label,insert text,add label,text overlay,custom text,add note,text placement"
|
||||
title = "Add Text"
|
||||
desc = "Add custom text anywhere in your PDF"
|
||||
desc = "Add custom text anywhere in your PDF with full positioning and formatting control"
|
||||
|
||||
[landing]
|
||||
addFiles = "Add Files"
|
||||
uploadFromComputer = "Upload from computer"
|
||||
openFromComputer = "Open from computer"
|
||||
mobileUpload = "Upload from Mobile"
|
||||
|
||||
[viewPdf]
|
||||
tags = "view,read,annotate,text,image,highlight,edit"
|
||||
@@ -1567,6 +1588,16 @@ bullet4 = "Best for sensitive or copyrighted content"
|
||||
1 = "Text"
|
||||
2 = "Image"
|
||||
|
||||
[watermark.help]
|
||||
title = "Advanced Watermark Features"
|
||||
tiledPattern = "Creating Tiled/Repeating Patterns"
|
||||
tiledDesc = "Use the Horizontal and Vertical Spacing controls to create a repeating watermark pattern across the entire page. Set both values to create a grid of watermarks (useful for 'CONFIDENTIAL' or 'DRAFT' labels)."
|
||||
tiledExample = "Example: Set horizontal spacing to 200 and vertical spacing to 150 to create a diagonal pattern of watermarks."
|
||||
security = "Security Flatten Option"
|
||||
securityDesc = "Enable 'Flatten PDF pages to images' to convert the PDF to images, making the watermark impossible to remove. Note: This increases file size and makes text non-selectable."
|
||||
multiLanguage = "Multi-Language Support"
|
||||
multiLanguageDesc = "Choose the appropriate Font/Language option (Roman, Arabic, Japanese, Korean, Chinese, Thai) to ensure your watermark text displays correctly with proper fonts."
|
||||
|
||||
[permissions]
|
||||
tags = "read,write,edit,print"
|
||||
title = "Change Permissions"
|
||||
@@ -2236,6 +2267,25 @@ tip4 = "Clean the scanner glass"
|
||||
headsUp = "Heads-up"
|
||||
headsUpDesc = "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
|
||||
|
||||
[scannerImageSplit.help]
|
||||
title = "Advanced OpenCV Parameters"
|
||||
overview = "This tool uses OpenCV (computer vision library) to automatically detect individual photos on scanned pages."
|
||||
angleThreshold = "Angle Threshold (Default: 5)"
|
||||
angleThresholdDesc = "Rotation angle in degrees needed before auto-straightening a photo. Lower values (1-3) straighten more aggressively, higher values (10-15) only straighten very tilted photos."
|
||||
tolerance = "Tolerance (Default: 20)"
|
||||
toleranceDesc = "How closely a colour must match the page background to count as background. Higher values (30-50) detect photos more easily but may include background noise. Lower values (10-15) are stricter."
|
||||
minArea = "Minimum Area (Default: 8000)"
|
||||
minAreaDesc = "Smallest photo size in pixels² to keep. Increase to 15,000-20,000 to filter out small fragments. Decrease to 3000-5000 to detect smaller photos."
|
||||
minContourArea = "Minimum Contour Area (Default: 500)"
|
||||
minContourAreaDesc = "Smallest edge/shape size when detecting photo boundaries. Increase to 1000-2000 to filter out dust and specks. Lower values detect finer edges."
|
||||
borderSize = "Border Size (Default: 1)"
|
||||
borderSizeDesc = "Extra padding in pixels around each extracted photo. Increase to 5-10 to avoid cutting edges. Set to 0 for no padding."
|
||||
recommendedSettings = "Recommended Settings"
|
||||
normalScans = "Normal photo scans: Use defaults (Angle: 5, Tolerance: 20, Min Area: 8000)"
|
||||
highQuality = "High-quality photos on clean background: Tolerance 15, Min Area 10000, Border 3"
|
||||
noisyScans = "Noisy/dirty scans: Tolerance 30, Min Contour Area 1500, Border 5"
|
||||
smallPhotos = "Small photos (ID cards, stamps): Min Area 3000, Border 2"
|
||||
|
||||
[sign]
|
||||
title = "Sign"
|
||||
header = "Sign PDFs"
|
||||
@@ -2409,6 +2459,31 @@ bullet2 = "Links still work when clicked"
|
||||
bullet3 = "Comments and notes remain visible"
|
||||
bullet4 = "Bookmarks still help you navigate"
|
||||
|
||||
[flatten.help]
|
||||
title = "Flatten PDF Guide"
|
||||
overview = "Flattening converts interactive PDF elements into static content. Makes forms non-editable and removes interactivity."
|
||||
whatGetsFlattened = "What Gets Flattened"
|
||||
fullFlatten = "Full Flatten (default): Text fields, checkboxes, radio buttons, dropdowns, buttons, annotations, and all interactive elements become static images/text."
|
||||
formsOnly = "Forms Only: Only form fields become static. Links, bookmarks, comments, and annotations remain interactive."
|
||||
whenToFlatten = "When to Flatten"
|
||||
useCase1 = "Completed forms: After filling out a form, flatten to prevent further changes"
|
||||
useCase2 = "Final documents: Create locked versions for record-keeping or distribution"
|
||||
useCase3 = "Watermarked docs: After adding watermarks, flatten to prevent removal"
|
||||
useCase4 = "Pre-printed forms: Convert fillable PDFs to printable forms with visible fields"
|
||||
useCase5 = "Consistency: Ensure PDF looks identical across all viewers and devices"
|
||||
whatStaysInteractive = "What Stays Interactive (Forms Only mode)"
|
||||
interactive1 = "Hyperlinks and web links remain clickable"
|
||||
interactive2 = "Bookmarks/table of contents for navigation"
|
||||
interactive3 = "Comments and annotations remain visible and editable"
|
||||
interactive4 = "Document outline and layers"
|
||||
important = "Important Notes"
|
||||
note1 = "Flattening is permanent - cannot be reversed. Keep original if you need to edit later."
|
||||
note2 = "File size may increase slightly as form elements become images"
|
||||
note3 = "Flattened forms cannot be un-flattened - the form data is permanently merged"
|
||||
note4 = "Digital signatures may be invalidated by flattening"
|
||||
alternatives = "Alternative to Flattening"
|
||||
altPermissions = "If you only want to prevent editing, consider using 'Change Permissions' instead of flattening. This keeps the PDF interactive but locked."
|
||||
|
||||
[repair]
|
||||
tags = "fix,restore,correction,recover"
|
||||
title = "Repair"
|
||||
@@ -2743,6 +2818,29 @@ bullet4 = "JKS – Java .jks keystore for dev / CI-CD workflows"
|
||||
title = "Key not listed?"
|
||||
text = "Convert your file to a Java keystore (.jks) with keytool, then pick JKS."
|
||||
|
||||
[certSign.help]
|
||||
title = "Digital Signature Quick Reference"
|
||||
overview = "Digital signatures provide cryptographic proof of document authenticity and integrity. They show who signed and detect any modifications after signing."
|
||||
manualVsAuto = "Signing Modes"
|
||||
manualMode = "Manual: Use your own certificate (PEM/P12/PKCS12/JKS). Shows as 'Trusted' if certificate authority is recognized. Best for legal/client-facing documents."
|
||||
autoMode = "Auto: Uses server-generated certificate. Always tamper-evident but shows as 'Unverified'. Fast, no setup. Best for internal workflows."
|
||||
certificateFormats = "Certificate Format Guide"
|
||||
pem = "PEM: Two files needed - certificate (.crt/.pem) + private key (.key). Common in Linux/web servers."
|
||||
p12pkcs12 = "P12/PKCS12/PFX: Single file with certificate + key, password-protected. Windows-friendly format."
|
||||
jks = "JKS: Java KeyStore format. Common in Java applications."
|
||||
visibleVsInvisible = "Signature Visibility"
|
||||
visible = "Visible: Shows signature block on PDF with name, date, reason, location. Choose page number for placement."
|
||||
invisible = "Invisible: No visual change to PDF. Signature embedded for verification only. Best when document layout mustn't change."
|
||||
security = "Security Notes"
|
||||
securityTamper = "Any edit after signing invalidates the signature - PDF readers will show 'Document Modified'"
|
||||
securityChain = "Certificate trust depends on Certificate Authority (CA) chain recognition by PDF readers"
|
||||
securityPassword = "Password-protected certificates (.p12/.jks) require password to sign"
|
||||
tips = "Tips"
|
||||
tip1 = "Test signatures with PDF readers (Adobe, browsers) to verify they show as expected"
|
||||
tip2 = "For legal documents, use Manual mode with a certificate from a recognized CA"
|
||||
tip3 = "For internal tracking/audit trails, Auto mode is simpler and equally tamper-evident"
|
||||
tip4 = "Invisible signatures don't alter page count or layout - useful for pre-printed forms"
|
||||
|
||||
[removeCertSign]
|
||||
tags = "authenticate,PEM,P12,official,decrypt"
|
||||
title = "Remove Certificate Signature"
|
||||
@@ -2867,6 +2965,27 @@ bullet2 = "Borders: Shows cut lines for trimming"
|
||||
bullet3 = "Gutter Margin: Adds space for binding/stapling"
|
||||
bullet4 = "Short-edge Flip: Only for automatic duplex printers"
|
||||
|
||||
[bookletImposition.help]
|
||||
title = "Booklet Printing Help"
|
||||
overview = "Booklet imposition arranges your PDF pages for professional booklet printing. Pages are reordered so when printed double-sided, folded, and stapled, they read in correct sequence."
|
||||
duplexOptions = "Duplex Printing Options"
|
||||
automaticDuplex = "Automatic Duplex: Enable 'Double-sided printing' - printer handles both sides automatically. Use 'Flip on short edge' if your printer requires it."
|
||||
manualDuplex = "Manual Duplex (No automatic duplex printer): Turn OFF 'Double-sided printing', use 1st Pass to print fronts, then 2nd Pass to print backs after reloading paper."
|
||||
gutterMargin = "Gutter Margin"
|
||||
gutterDesc = "Adds extra space along the binding edge (inner margin) so text doesn't get lost in the fold. Typical values: 18-36 points (0.25-0.5 inches). Larger booklets need more gutter."
|
||||
spineLocation = "Spine Location"
|
||||
spineDesc = "Left (standard): For left-to-right languages (English, etc.). Right (RTL): For right-to-left languages (Arabic, Hebrew, etc.)."
|
||||
borderOption = "Border Option"
|
||||
borderDesc = "Adds visible borders around each page section. Useful for cutting guides or checking alignment before printing final copies."
|
||||
quickSteps = "Quick Steps"
|
||||
step1 = "1. Upload your PDF (must have even number of pages - tool will add blank if needed)"
|
||||
step2 = "2. Choose automatic duplex (recommended) or manual duplex mode"
|
||||
step3 = "3. Set gutter margin if binding/stapling (18-36 points typical)"
|
||||
step4 = "4. Download and print double-sided (flip on long edge for most printers)"
|
||||
step5 = "5. Fold printed sheets in half, stack in order, staple along spine"
|
||||
paperSize = "Paper Size"
|
||||
paperSizeDesc = "Automatically detected from your PDF. Booklet pages will be arranged 2-up on landscape-oriented sheets (e.g., two letter pages side-by-side on ledger)."
|
||||
|
||||
[bookletImposition.error]
|
||||
failed = "An error occurred while creating the booklet imposition."
|
||||
|
||||
@@ -3018,6 +3137,30 @@ submit = "Submit"
|
||||
3 = "Upload the single large scanned PDF file and let Stirling PDF handle the rest."
|
||||
4 = "Divider pages are automatically detected and removed, guaranteeing a neat final document."
|
||||
|
||||
[autoSplitPDF.help]
|
||||
title = "QR Code Auto-Split Help"
|
||||
overview = "This tool automatically splits scanned PDFs using special QR code divider pages. Perfect for batch scanning multiple documents."
|
||||
howItWorks = "How It Works"
|
||||
step1 = "1. Download and print the QR code divider page (black & white is fine)"
|
||||
step2 = "2. Place divider pages between your documents"
|
||||
step3 = "3. Scan all documents in one batch (dividers included)"
|
||||
step4 = "4. Upload to Stirling-PDF - documents are automatically separated and dividers removed"
|
||||
duplexMode = "Duplex Mode (Double-Sided Scanning)"
|
||||
duplexDesc = "Enable 'Duplex Mode' when scanning double-sided documents. This automatically skips the back sides of divider pages, preventing blank pages in your output."
|
||||
duplexExample = "Example: With duplex ON, if you scan a divider followed by a 2-page document, the tool knows the divider's back is blank and skips it."
|
||||
qrCodes = "Valid QR Codes"
|
||||
qrDesc = "Only QR codes from Stirling-PDF divider pages work. These contain specific URLs: github.com/Stirling-Tools/Stirling-PDF, github.com/Frooodle/Stirling-PDF, or stirlingpdf.com"
|
||||
useCases = "Best Use Cases"
|
||||
useCase1 = "Batch scanning multiple contracts, forms, or reports"
|
||||
useCase2 = "Digitizing physical file folders"
|
||||
useCase3 = "Scanning stacks of invoices or receipts"
|
||||
useCase4 = "Processing mail or paperwork in bulk"
|
||||
tips = "Tips"
|
||||
tip1 = "Print dividers once, reuse them for all your scanning sessions"
|
||||
tip2 = "Dividers work in black & white - no need for colour printing"
|
||||
tip3 = "Make sure QR codes are clearly visible (not crumpled or dirty)"
|
||||
tip4 = "For best results, use the same paper weight for dividers as your documents"
|
||||
|
||||
[sanitizePdf]
|
||||
tags = "clean,secure,safe,remove-threats"
|
||||
|
||||
@@ -3287,6 +3430,31 @@ placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
||||
[redact.manual.redactionColor]
|
||||
title = "Redaction Colour"
|
||||
|
||||
[redact.help]
|
||||
title = "Redaction Help & Regex Patterns"
|
||||
overview = "Overview"
|
||||
overviewDesc = "Redaction permanently removes sensitive content from PDFs. Text behind redaction boxes is completely removed, not just covered. Metadata is also cleaned automatically."
|
||||
regexMode = "Regular Expression (Regex) Mode"
|
||||
regexDesc = "Enable 'Use Regex' to use pattern-based matching for complex searches. Powerful for finding multiple instances of similar data."
|
||||
regexExamples = "Common Regex Patterns"
|
||||
regexSSN = "Social Security Number: \\d{3}-\\d{2}-\\d{4} (matches XXX-XX-XXXX)"
|
||||
regexPhone = "Phone Number: \\(?\\d{3}\\)?[-.]?\\d{3}[-.]?\\d{4} (matches various phone formats)"
|
||||
regexEmail = "Email Address: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,} (matches email addresses)"
|
||||
regexCreditCard = "Credit Card: \\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{4} (matches card numbers)"
|
||||
regexDate = "Date (YYYY-MM-DD): \\d{4}-\\d{2}-\\d{2}"
|
||||
regexZipCode = "US ZIP Code: \\d{5}(-\\d{4})? (matches 12345 or 12345-6789)"
|
||||
regexIPAddress = "IP Address: \\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"
|
||||
wholeWordMode = "Whole Word Search"
|
||||
wholeWordDesc = "Enable 'Whole Word Search' to match only complete words. Example: 'John' won't match 'Johnson' when enabled. Useful for names."
|
||||
securityFeatures = "Security Features"
|
||||
securityConvert = "Convert to PDF-Image: Converts the entire PDF to images after redaction. This ensures text is truly unrecoverable. Note: Increases file size and makes text non-selectable."
|
||||
securityMetadata = "Automatic Metadata Cleaning: Automatically removes author, subject, keywords, and XMP metadata for privacy."
|
||||
tips = "Best Practices"
|
||||
tip1 = "Test First: Test your regex patterns on a copy before using on important documents."
|
||||
tip2 = "Review Results: Always review redacted documents to ensure all sensitive information is removed."
|
||||
tip3 = "Use Convert to Image: For highly sensitive documents, enable 'Convert to PDF-Image' to ensure complete removal."
|
||||
tip4 = "Padding: Add custom padding to ensure redaction boxes fully cover text (useful for different font sizes)."
|
||||
|
||||
[tableExtraxt]
|
||||
tags = "CSV,Table Extraction,extract,convert"
|
||||
|
||||
@@ -3406,6 +3574,35 @@ failed = "An error occurred while adding stamp to the PDF."
|
||||
[AddStampRequest.results]
|
||||
title = "Stamp Results"
|
||||
|
||||
[AddStampRequest.help]
|
||||
title = "Stamp Tool Help"
|
||||
overview = "Add text or image stamps to PDFs at precise locations. Supports variable substitution and multi-language fonts."
|
||||
variables = "Variable Substitution (Text Stamps)"
|
||||
variablesDesc = "Use these variables in your stamp text for dynamic content:"
|
||||
variableN = "{n} - Current page number"
|
||||
variableTotal = "{total} - Total pages"
|
||||
variableFilename = "{filename} - Document filename"
|
||||
variableExample = "Example: 'Document {filename} - Page {n}' becomes 'Document myfile - Page 5'"
|
||||
positioning = "Positioning System"
|
||||
positionGrid = "Quick Position: Use 1-9 grid (1=top-left, 5=center, 9=bottom-right) for fast placement"
|
||||
positionOverride = "Override Coordinates: Enter exact X/Y pixel coordinates for precise placement (overrides grid position)"
|
||||
positionMargin = "Custom Margin: Adjust distance from page edges (Small/Medium/Large/X-Large)"
|
||||
multiLanguage = "Multi-Language Support"
|
||||
multiLanguageDesc = "Choose alphabet/language for proper font rendering: Roman, Arabic, Japanese, Korean, Chinese, Thai. Essential for non-Latin text."
|
||||
formatting = "Formatting Options"
|
||||
rotation = "Rotation: -360° to 360° for angled stamps"
|
||||
opacity = "Opacity: 0-100% for transparency (lower = more transparent)"
|
||||
color = "Custom Colour: Choose any colour for text stamps"
|
||||
fontSize = "Font/Image Size: Adjust size of text or image stamps"
|
||||
stampTypes = "Stamp Types"
|
||||
textStamp = "Text: Quick approval stamps ('APPROVED', 'REVIEWED', 'CONFIDENTIAL'), custom messages"
|
||||
imageStamp = "Image: Upload logos, signatures, or custom graphics"
|
||||
useCases = "Common Use Cases"
|
||||
useCase1 = "Approval stamps: 'APPROVED', 'REVIEWED BY [NAME]', 'CONFIDENTIAL'"
|
||||
useCase2 = "Business stamps: Company logos, official seals"
|
||||
useCase3 = "Page references: 'Page {n} of {total}'"
|
||||
useCase4 = "Document identifiers: '{filename} - {n}'"
|
||||
|
||||
[removeImagePdf]
|
||||
tags = "Remove Image,Page operations,Back end,server side"
|
||||
|
||||
@@ -3770,6 +3967,29 @@ text = "Convert pages to high-contrast black and white using ImageMagick. Use de
|
||||
[compress.error]
|
||||
failed = "An error occurred while compressing the PDF."
|
||||
|
||||
[compress.help]
|
||||
title = "PDF Compression Guide"
|
||||
overview = "Reduce PDF file size while balancing quality. Uses qpdf for compression and optimization."
|
||||
methods = "Compression Methods"
|
||||
qualityMethod = "Quality: Choose compression strength (1-9). Lower preserves quality, higher reduces size more aggressively. Recommended: 3-5 for most documents."
|
||||
filesizeMethod = "File Size: Enter target size - tool automatically adjusts quality to reach it. Best when you have size limits (email attachments, etc.)."
|
||||
options = "Additional Options"
|
||||
grayscale = "Grayscale: Converts all colours to black & white. Dramatically reduces size for color-heavy documents. Best for: Text documents, reports, forms."
|
||||
lineArt = "Line Art: Maximum compression. Converts to high-contrast black & white (requires ImageMagick). Best for: Text-only documents, technical drawings. NOT recommended for photos."
|
||||
qualityLevels = "Quality Level Guide"
|
||||
level1to3 = "1-3: High quality. Minimal compression. Use for important documents, presentations, or photos."
|
||||
level4to6 = "4-6: Balanced. Noticeable compression with acceptable quality. Good for general use."
|
||||
level7to9 = "7-9: Maximum compression. Lower quality but smallest files. Use for drafts, internal documents."
|
||||
tips = "Tips"
|
||||
tip1 = "Start with quality level 3-4 and increase if more compression needed"
|
||||
tip2 = "Grayscale option works well for scanned documents without photos"
|
||||
tip3 = "Line art is extreme - preview results before using on final documents"
|
||||
tip4 = "File size method is convenient but may produce varying quality across pages"
|
||||
expectations = "Size Reduction Expectations"
|
||||
typical = "Typical: 20-40% reduction for standard PDFs"
|
||||
images = "Image-heavy: 50-70% reduction with grayscale"
|
||||
extreme = "Line art: 80-95% reduction (text becomes images)"
|
||||
|
||||
[compress.selectText]
|
||||
2 = "Optimisation level:"
|
||||
4 = "Auto mode - Auto adjusts quality to get PDF to exact size"
|
||||
@@ -4018,92 +4238,23 @@ deleteSelected = "Delete Selected Pages"
|
||||
closePdf = "Close PDF"
|
||||
exportAll = "Export PDF"
|
||||
downloadSelected = "Download Selected Files"
|
||||
annotations = "Annotations"
|
||||
exportSelected = "Export Selected Pages"
|
||||
saveChanges = "Save Changes"
|
||||
downloadAll = "Download All"
|
||||
saveAll = "Save All"
|
||||
toggleTheme = "Toggle Theme"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
language = "Language"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
search = "Search PDF"
|
||||
panMode = "Pan Mode"
|
||||
rotateLeft = "Rotate Left"
|
||||
rotateRight = "Rotate Right"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleBookmarks = "Toggle Bookmarks"
|
||||
exportSelected = "Export Selected Pages"
|
||||
toggleAnnotations = "Toggle Annotations Visibility"
|
||||
annotationMode = "Toggle Annotation Mode"
|
||||
print = "Print PDF"
|
||||
downloadAll = "Download All"
|
||||
saveAll = "Save All"
|
||||
|
||||
[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"
|
||||
draw = "Draw"
|
||||
save = "Save"
|
||||
saveChanges = "Save Changes"
|
||||
|
||||
[search]
|
||||
title = "Search PDF"
|
||||
@@ -4879,6 +5030,8 @@ googleDriveShort = "Drive"
|
||||
myFiles = "My Files"
|
||||
noRecentFiles = "No recent files found"
|
||||
googleDriveNotAvailable = "Google Drive integration not available"
|
||||
mobileUpload = "Mobile Upload"
|
||||
mobileShort = "Mobile"
|
||||
downloadSelected = "Download Selected"
|
||||
saveSelected = "Save Selected"
|
||||
openFiles = "Open Files"
|
||||
@@ -6328,3 +6481,42 @@ title = "Add Text Results"
|
||||
|
||||
[addText.error]
|
||||
failed = "An error occurred while adding text to the PDF."
|
||||
|
||||
[mobileUpload]
|
||||
title = "Upload from Mobile"
|
||||
description = "Scan this QR code with your mobile device to upload photos directly to this page."
|
||||
error = "Connection Error"
|
||||
pollingError = "Error checking for files"
|
||||
sessionId = "Session ID"
|
||||
filesReceived = "{{count}} file(s) received"
|
||||
connected = "Mobile device connected"
|
||||
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
|
||||
|
||||
[mobileScanner]
|
||||
title = "Mobile Scanner"
|
||||
noSession = "Invalid Session"
|
||||
noSessionMessage = "Please scan a valid QR code to access this page."
|
||||
uploadSuccess = "Upload Successful!"
|
||||
uploadSuccessMessage = "Your images have been transferred."
|
||||
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
|
||||
uploadFailed = "Upload failed. Please try again."
|
||||
uploading = "Uploading..."
|
||||
connected = "Connected"
|
||||
connecting = "Connecting..."
|
||||
camera = "Camera"
|
||||
fileUpload = "File Upload"
|
||||
cameraAccessDenied = "Camera access denied. Please enable camera access."
|
||||
liveDetection = "Live Detection"
|
||||
autoEnhance = "Auto-enhance"
|
||||
autoEnhanceInfo = "Document edges will be automatically detected and perspective corrected"
|
||||
flashlight = "Flashlight"
|
||||
processing = "Processing..."
|
||||
capture = "Capture Photo"
|
||||
selectImage = "Select Image"
|
||||
preview = "Preview"
|
||||
retake = "Retake"
|
||||
addToBatch = "Add to Batch"
|
||||
upload = "Upload"
|
||||
batchImages = "Batch"
|
||||
clearBatch = "Clear"
|
||||
uploadAll = "Upload All"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+263
@@ -0,0 +1,263 @@
|
||||
/*! jscanify v1.4.0 | (c) ColonelParrot and other contributors | MIT License */
|
||||
|
||||
(function (global, factory) {
|
||||
typeof exports === "object" && typeof module !== "undefined"
|
||||
? (module.exports = factory())
|
||||
: typeof define === "function" && define.amd
|
||||
? define(factory)
|
||||
: (global.jscanify = factory());
|
||||
})(this, function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Calculates distance between two points. Each point must have `x` and `y` property
|
||||
* @param {*} p1 point 1
|
||||
* @param {*} p2 point 2
|
||||
* @returns distance between two points
|
||||
*/
|
||||
function distance(p1, p2) {
|
||||
return Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
}
|
||||
|
||||
class jscanify {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* Finds the contour of the paper within the image
|
||||
* @param {*} img image to process (cv.Mat)
|
||||
* @returns the biggest contour inside the image
|
||||
*/
|
||||
findPaperContour(img) {
|
||||
const imgGray = new cv.Mat();
|
||||
cv.Canny(img, imgGray, 50, 200);
|
||||
|
||||
const imgBlur = new cv.Mat();
|
||||
cv.GaussianBlur(
|
||||
imgGray,
|
||||
imgBlur,
|
||||
new cv.Size(3, 3),
|
||||
0,
|
||||
0,
|
||||
cv.BORDER_DEFAULT
|
||||
);
|
||||
|
||||
const imgThresh = new cv.Mat();
|
||||
cv.threshold(
|
||||
imgBlur,
|
||||
imgThresh,
|
||||
0,
|
||||
255,
|
||||
cv.THRESH_OTSU
|
||||
);
|
||||
|
||||
let contours = new cv.MatVector();
|
||||
let hierarchy = new cv.Mat();
|
||||
|
||||
cv.findContours(
|
||||
imgThresh,
|
||||
contours,
|
||||
hierarchy,
|
||||
cv.RETR_CCOMP,
|
||||
cv.CHAIN_APPROX_SIMPLE
|
||||
);
|
||||
|
||||
let maxArea = 0;
|
||||
let maxContourIndex = -1;
|
||||
for (let i = 0; i < contours.size(); ++i) {
|
||||
let contourArea = cv.contourArea(contours.get(i));
|
||||
if (contourArea > maxArea) {
|
||||
maxArea = contourArea;
|
||||
maxContourIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const maxContour =
|
||||
maxContourIndex >= 0 ?
|
||||
contours.get(maxContourIndex) :
|
||||
null;
|
||||
|
||||
imgGray.delete();
|
||||
imgBlur.delete();
|
||||
imgThresh.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
return maxContour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the paper detected inside the image.
|
||||
* @param {*} image image to process
|
||||
* @param {*} options options for highlighting. Accepts `color` and `thickness` parameter
|
||||
* @returns `HTMLCanvasElement` with original image and paper highlighted
|
||||
*/
|
||||
highlightPaper(image, options) {
|
||||
options = options || {};
|
||||
options.color = options.color || "orange";
|
||||
options.thickness = options.thickness || 10;
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = cv.imread(image);
|
||||
|
||||
const maxContour = this.findPaperContour(img);
|
||||
cv.imshow(canvas, img);
|
||||
if (maxContour) {
|
||||
const {
|
||||
topLeftCorner,
|
||||
topRightCorner,
|
||||
bottomLeftCorner,
|
||||
bottomRightCorner,
|
||||
} = this.getCornerPoints(maxContour, img);
|
||||
|
||||
if (
|
||||
topLeftCorner &&
|
||||
topRightCorner &&
|
||||
bottomLeftCorner &&
|
||||
bottomRightCorner
|
||||
) {
|
||||
ctx.strokeStyle = options.color;
|
||||
ctx.lineWidth = options.thickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(...Object.values(topLeftCorner));
|
||||
ctx.lineTo(...Object.values(topRightCorner));
|
||||
ctx.lineTo(...Object.values(bottomRightCorner));
|
||||
ctx.lineTo(...Object.values(bottomLeftCorner));
|
||||
ctx.lineTo(...Object.values(topLeftCorner));
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
img.delete();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and undistorts the image detected within the frame.
|
||||
*
|
||||
* Returns `null` if no paper is detected.
|
||||
*
|
||||
* @param {*} image image to process
|
||||
* @param {*} resultWidth desired result paper width
|
||||
* @param {*} resultHeight desired result paper height
|
||||
* @param {*} cornerPoints optional custom corner points, in case automatic corner points are incorrect
|
||||
* @returns `HTMLCanvasElement` containing undistorted image
|
||||
*/
|
||||
extractPaper(image, resultWidth, resultHeight, cornerPoints) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const img = cv.imread(image);
|
||||
const maxContour = cornerPoints ? null : this.findPaperContour(img);
|
||||
|
||||
if(maxContour == null && cornerPoints === undefined){
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
topLeftCorner,
|
||||
topRightCorner,
|
||||
bottomLeftCorner,
|
||||
bottomRightCorner,
|
||||
} = cornerPoints || this.getCornerPoints(maxContour, img);
|
||||
let warpedDst = new cv.Mat();
|
||||
|
||||
let dsize = new cv.Size(resultWidth, resultHeight);
|
||||
let srcTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||
topLeftCorner.x,
|
||||
topLeftCorner.y,
|
||||
topRightCorner.x,
|
||||
topRightCorner.y,
|
||||
bottomLeftCorner.x,
|
||||
bottomLeftCorner.y,
|
||||
bottomRightCorner.x,
|
||||
bottomRightCorner.y,
|
||||
]);
|
||||
|
||||
let dstTri = cv.matFromArray(4, 1, cv.CV_32FC2, [
|
||||
0,
|
||||
0,
|
||||
resultWidth,
|
||||
0,
|
||||
0,
|
||||
resultHeight,
|
||||
resultWidth,
|
||||
resultHeight,
|
||||
]);
|
||||
|
||||
let M = cv.getPerspectiveTransform(srcTri, dstTri);
|
||||
cv.warpPerspective(
|
||||
img,
|
||||
warpedDst,
|
||||
M,
|
||||
dsize,
|
||||
cv.INTER_LINEAR,
|
||||
cv.BORDER_CONSTANT,
|
||||
new cv.Scalar()
|
||||
);
|
||||
|
||||
cv.imshow(canvas, warpedDst);
|
||||
|
||||
img.delete()
|
||||
warpedDst.delete()
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the corner points of a contour.
|
||||
* @param {*} contour contour from {@link findPaperContour}
|
||||
* @returns object with properties `topLeftCorner`, `topRightCorner`, `bottomLeftCorner`, `bottomRightCorner`, each with `x` and `y` property
|
||||
*/
|
||||
getCornerPoints(contour) {
|
||||
let rect = cv.minAreaRect(contour);
|
||||
const center = rect.center;
|
||||
|
||||
let topLeftCorner;
|
||||
let topLeftCornerDist = 0;
|
||||
|
||||
let topRightCorner;
|
||||
let topRightCornerDist = 0;
|
||||
|
||||
let bottomLeftCorner;
|
||||
let bottomLeftCornerDist = 0;
|
||||
|
||||
let bottomRightCorner;
|
||||
let bottomRightCornerDist = 0;
|
||||
|
||||
for (let i = 0; i < contour.data32S.length; i += 2) {
|
||||
const point = { x: contour.data32S[i], y: contour.data32S[i + 1] };
|
||||
const dist = distance(point, center);
|
||||
if (point.x < center.x && point.y < center.y) {
|
||||
// top left
|
||||
if (dist > topLeftCornerDist) {
|
||||
topLeftCorner = point;
|
||||
topLeftCornerDist = dist;
|
||||
}
|
||||
} else if (point.x > center.x && point.y < center.y) {
|
||||
// top right
|
||||
if (dist > topRightCornerDist) {
|
||||
topRightCorner = point;
|
||||
topRightCornerDist = dist;
|
||||
}
|
||||
} else if (point.x < center.x && point.y > center.y) {
|
||||
// bottom left
|
||||
if (dist > bottomLeftCornerDist) {
|
||||
bottomLeftCorner = point;
|
||||
bottomLeftCornerDist = dist;
|
||||
}
|
||||
} else if (point.x > center.x && point.y > center.y) {
|
||||
// bottom right
|
||||
if (dist > bottomRightCornerDist) {
|
||||
bottomRightCorner = point;
|
||||
bottomRightCornerDist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
topLeftCorner,
|
||||
topRightCorner,
|
||||
bottomLeftCorner,
|
||||
bottomRightCorner,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return jscanify;
|
||||
});
|
||||
+48
File diff suppressed because one or more lines are too long
@@ -1,8 +1,12 @@
|
||||
import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
@@ -13,15 +17,44 @@ import "@app/styles/index.css";
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
{children}
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
<Routes>
|
||||
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
@@ -96,15 +95,13 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
interface ColorPickerProps {
|
||||
@@ -8,10 +8,6 @@ interface ColorPickerProps {
|
||||
selectedColor: string;
|
||||
onColorChange: (color: string) => void;
|
||||
title?: string;
|
||||
opacity?: number;
|
||||
onOpacityChange?: (opacity: number) => void;
|
||||
showOpacity?: boolean;
|
||||
opacityLabel?: string;
|
||||
}
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
@@ -19,15 +15,10 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title,
|
||||
opacity,
|
||||
onOpacityChange,
|
||||
showOpacity = false,
|
||||
opacityLabel,
|
||||
title
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -47,23 +38,6 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
size="lg"
|
||||
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">
|
||||
<Button onClick={onClose}>
|
||||
{t('common.done', 'Done')}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
@@ -12,8 +11,6 @@ interface TextInputWithFontProps {
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
@@ -33,8 +30,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
onFontFamilyChange,
|
||||
textColor = '#000000',
|
||||
onTextColorChange,
|
||||
textAlign = 'left',
|
||||
onTextAlignChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder,
|
||||
@@ -44,7 +39,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
colorLabel,
|
||||
onAnyChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Text, Button, Group } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import PhonelinkIcon from '@mui/icons-material/Phonelink';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
@@ -15,12 +17,13 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const UploadIcon = icons.upload;
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
@@ -33,6 +36,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileUploadClick = () => {
|
||||
setMobileUploadModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFilesReceivedFromMobile = (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
onNewFilesSelect(files);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
|
||||
@@ -105,24 +118,59 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<PhonelinkIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={handleMobileUploadClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (horizontal) {
|
||||
return (
|
||||
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||
{buttons}
|
||||
</Group>
|
||||
<>
|
||||
<Group gap="xs" justify="center" style={{ width: '100%' }}>
|
||||
{buttons}
|
||||
</Group>
|
||||
<MobileUploadModal
|
||||
opened={mobileUploadModalOpen}
|
||||
onClose={() => setMobileUploadModalOpen(false)}
|
||||
onFilesReceived={handleFilesReceivedFromMobile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||
{t('fileManager.myFiles', 'My Files')}
|
||||
</Text>
|
||||
{buttons}
|
||||
</Stack>
|
||||
<>
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
|
||||
{t('fileManager.myFiles', 'My Files')}
|
||||
</Text>
|
||||
{buttons}
|
||||
</Stack>
|
||||
<MobileUploadModal
|
||||
opened={mobileUploadModalOpen}
|
||||
onClose={() => setMobileUploadModalOpen(false)}
|
||||
onFilesReceived={handleFilesReceivedFromMobile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { Container, Button, Group, useMantineColorScheme, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -11,6 +11,8 @@ import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||
import PhonelinkIcon from '@mui/icons-material/Phonelink';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -24,6 +26,7 @@ const LandingPage = () => {
|
||||
const { wordmark } = useLogoAssets();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
||||
const [mobileUploadModalOpen, setMobileUploadModalOpen] = React.useState(false);
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
|
||||
@@ -48,6 +51,16 @@ const LandingPage = () => {
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleMobileUploadClick = () => {
|
||||
setMobileUploadModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFilesReceivedFromMobile = async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine if the user has any recent files (same source as File Manager)
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -202,32 +215,58 @@ const LandingPage = () => {
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
|
||||
<ActionIcon
|
||||
size={38}
|
||||
variant="subtle"
|
||||
onClick={handleMobileUploadClick}
|
||||
style={{
|
||||
color: 'var(--accent-interactive)',
|
||||
}}
|
||||
>
|
||||
<PhonelinkIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{!hasRecents && (
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
minWidth: '58px',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: 'calc(100% - 38px - 0.6rem)',
|
||||
minWidth: '58px',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
</Button>
|
||||
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
|
||||
<ActionIcon
|
||||
size={38}
|
||||
variant="subtle"
|
||||
onClick={handleMobileUploadClick}
|
||||
style={{
|
||||
color: 'var(--accent-interactive)',
|
||||
}}
|
||||
>
|
||||
<PhonelinkIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -251,6 +290,11 @@ const LandingPage = () => {
|
||||
</span>
|
||||
</div>
|
||||
</Dropzone>
|
||||
<MobileUploadModal
|
||||
opened={mobileUploadModalOpen}
|
||||
onClose={() => setMobileUploadModalOpen(false)}
|
||||
onFilesReceived={handleFilesReceivedFromMobile}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { Modal, Stack, Text, Badge, Box, Group, Alert } from '@mantine/core';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFrontendUrl } from '@app/hooks/useFrontendUrl';
|
||||
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
|
||||
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { withBasePath } from '@app/constants/app';
|
||||
|
||||
interface MobileUploadModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onFilesReceived: (files: File[]) => void;
|
||||
}
|
||||
|
||||
// Generate a UUID-like session ID
|
||||
function generateSessionId(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* MobileUploadModal
|
||||
*
|
||||
* Displays a QR code that mobile devices can scan to upload files via backend server.
|
||||
* Files are temporarily stored on server and retrieved by desktop.
|
||||
*/
|
||||
export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const frontendUrl = useFrontendUrl();
|
||||
|
||||
const [sessionId] = useState(() => generateSessionId());
|
||||
const [filesReceived, setFilesReceived] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pollIntervalRef = useRef<number | null>(null);
|
||||
const processedFiles = useRef<Set<string>>(new Set());
|
||||
|
||||
// Use configured frontendUrl if set, otherwise use current origin
|
||||
// Combine with base path and mobile-scanner route
|
||||
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
|
||||
|
||||
const pollForFiles = useCallback(async () => {
|
||||
if (!opened) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check for files');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const files = data.files || [];
|
||||
|
||||
// Download only files we haven't processed yet
|
||||
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
for (const fileMetadata of newFiles) {
|
||||
try {
|
||||
const downloadResponse = await fetch(
|
||||
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`
|
||||
);
|
||||
|
||||
if (downloadResponse.ok) {
|
||||
const blob = await downloadResponse.blob();
|
||||
const file = new File([blob], fileMetadata.filename, {
|
||||
type: fileMetadata.contentType || 'image/jpeg'
|
||||
});
|
||||
|
||||
processedFiles.current.add(fileMetadata.filename);
|
||||
setFilesReceived((prev) => prev + 1);
|
||||
onFilesReceived([file]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to download file:', fileMetadata.filename, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the entire session immediately after downloading all files
|
||||
// This ensures files are only on server for ~1 second
|
||||
try {
|
||||
await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' });
|
||||
console.log('Session cleaned up after file download');
|
||||
} catch (cleanupErr) {
|
||||
console.warn('Failed to cleanup session after download:', cleanupErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error polling for files:', err);
|
||||
setError(t('mobileUpload.pollingError', 'Error checking for files'));
|
||||
}
|
||||
}, [opened, sessionId, onFilesReceived, t]);
|
||||
|
||||
// Start polling when modal opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setFilesReceived(0);
|
||||
setError(null);
|
||||
processedFiles.current.clear();
|
||||
|
||||
// Poll every 2 seconds
|
||||
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
|
||||
|
||||
// Initial poll
|
||||
pollForFiles();
|
||||
} else {
|
||||
// Stop polling when modal closes
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [opened, pollForFiles]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={t('mobileUpload.title', 'Upload from Mobile')}
|
||||
centered
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'mobileUpload.description',
|
||||
'Scan this QR code with your mobile device to upload photos directly to this page.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
|
||||
title={t('mobileUpload.error', 'Connection Error')}
|
||||
color="red"
|
||||
>
|
||||
<Text size="sm">{error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Box
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
background: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
|
||||
</Box>
|
||||
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('mobileUpload.sessionId', 'Session ID')}:
|
||||
</Text>
|
||||
<Badge variant="light" color="blue" size="lg">
|
||||
{sessionId}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{filesReceived > 0 && (
|
||||
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
|
||||
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
|
||||
{t(
|
||||
'mobileUpload.instructions',
|
||||
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
wordBreak: 'break-all',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{mobileUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignMode = selectedTool === 'sign';
|
||||
|
||||
// Check if we're in any annotation tool that should disable the toggle
|
||||
const isInAnnotationTool = selectedTool === 'annotate' || selectedTool === 'sign' || selectedTool === 'addImage' || selectedTool === 'addText';
|
||||
|
||||
// Check if we're on annotate tool to highlight the button
|
||||
const isAnnotateActive = selectedTool === 'annotate';
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
@@ -41,14 +35,13 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isAnnotateActive ? "filled" : "subtle"}
|
||||
color="blue"
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={disabled || currentView !== 'viewer' || isInAnnotationTool}
|
||||
disabled={disabled || currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent, TooltipTip } from '@app/types/tips';
|
||||
|
||||
export const useAddPageNumbersTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("addPageNumbers.help.title", "Page Numbers & Bates Numbering Help")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("addPageNumbers.help.variables", "Variable Substitution"),
|
||||
description: t("addPageNumbers.help.variablesDesc", "Use these variables in the Custom Text Format field to create dynamic page numbering:"),
|
||||
bullets: [
|
||||
t("addPageNumbers.help.variableN", "{n} - Current page number"),
|
||||
t("addPageNumbers.help.variableTotal", "{total} - Total number of pages"),
|
||||
t("addPageNumbers.help.variableFilename", "{filename} - Document filename (without extension)")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("addPageNumbers.help.examples", "Common Examples"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("addPageNumbers.help.exampleSimple", "Simple numbering: {n} → 1, 2, 3, 4..."),
|
||||
t("addPageNumbers.help.examplePageOf", "Page X of Y: Page {n} of {total} → Page 1 of 10, Page 2 of 10..."),
|
||||
t("addPageNumbers.help.exampleBates", "Legal Bates numbering: ABC-{n} → ABC-001, ABC-002, ABC-003..."),
|
||||
t("addPageNumbers.help.exampleDocument", "Document reference: {filename}-{n} → mydoc-001, mydoc-002..."),
|
||||
t("addPageNumbers.help.exampleCustom", "Custom format: Doc {filename} | Page {n}/{total}")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("addPageNumbers.help.positioning", "Positioning"),
|
||||
description: t("addPageNumbers.help.positioningDesc", "Use the 1-9 grid system to quickly position page numbers, or use the margin size to adjust distance from edges.")
|
||||
},
|
||||
{
|
||||
title: t("addPageNumbers.help.tips", "Tips"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("addPageNumbers.help.tip1", "Starting Number: Change the starting number to begin counting from any value (useful for multi-part documents)"),
|
||||
t("addPageNumbers.help.tip2", "Page Selection: Number only specific pages by entering ranges like 1,3,5-8"),
|
||||
t("addPageNumbers.help.tip3", "Formatting: Choose font type, size, and color to match your document style")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useAddStampSetupTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("AddStampRequest.help.title", "Stamp Tool Help")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("AddStampRequest.help.overview", "Overview"),
|
||||
description: t("AddStampRequest.help.overview", "Add text or image stamps to PDFs at precise locations. Supports variable substitution and multi-language fonts.")
|
||||
},
|
||||
{
|
||||
title: t("AddStampRequest.help.stampTypes", "Stamp Types"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("AddStampRequest.help.textStamp", "Text: Quick approval stamps ('APPROVED', 'REVIEWED', 'CONFIDENTIAL'), custom messages"),
|
||||
t("AddStampRequest.help.imageStamp", "Image: Upload logos, signatures, or custom graphics")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("AddStampRequest.help.variables", "Variable Substitution (Text Stamps)"),
|
||||
description: t("AddStampRequest.help.variablesDesc", "Use these variables in your stamp text for dynamic content:"),
|
||||
bullets: [
|
||||
t("AddStampRequest.help.variableN", "{n} - Current page number"),
|
||||
t("AddStampRequest.help.variableTotal", "{total} - Total pages"),
|
||||
t("AddStampRequest.help.variableFilename", "{filename} - Document filename"),
|
||||
t("AddStampRequest.help.variableExample", "Example: 'Document {filename} - Page {n}' becomes 'Document myfile - Page 5'")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("AddStampRequest.help.multiLanguage", "Multi-Language Support"),
|
||||
description: t("AddStampRequest.help.multiLanguageDesc", "Choose alphabet/language for proper font rendering: Roman, Arabic, Japanese, Korean, Chinese, Thai. Essential for non-Latin text.")
|
||||
},
|
||||
{
|
||||
title: t("AddStampRequest.help.useCases", "Common Use Cases"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("AddStampRequest.help.useCase1", "Approval stamps: 'APPROVED', 'REVIEWED BY [NAME]', 'CONFIDENTIAL'"),
|
||||
t("AddStampRequest.help.useCase2", "Business stamps: Company logos, official seals"),
|
||||
t("AddStampRequest.help.useCase3", "Page references: 'Page {n} of {total}'"),
|
||||
t("AddStampRequest.help.useCase4", "Document identifiers: '{filename} - {n}'")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const useAddStampPositionTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("AddStampRequest.help.positioningTitle", "Positioning & Formatting")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("AddStampRequest.help.positioning", "Positioning System"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("AddStampRequest.help.positionGrid", "Quick Position: Use 1-9 grid (1=top-left, 5=center, 9=bottom-right) for fast placement"),
|
||||
t("AddStampRequest.help.positionOverride", "Override Coordinates: Enter exact X/Y pixel coordinates for precise placement (overrides grid position)"),
|
||||
t("AddStampRequest.help.positionMargin", "Custom Margin: Adjust distance from page edges (Small/Medium/Large/X-Large)")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("AddStampRequest.help.formatting", "Formatting Options"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("AddStampRequest.help.rotation", "Rotation: -360° to 360° for angled stamps"),
|
||||
t("AddStampRequest.help.opacity", "Opacity: 0-100% for transparency (lower = more transparent)"),
|
||||
t("AddStampRequest.help.color", "Custom Colour: Choose any colour for text stamps"),
|
||||
t("AddStampRequest.help.fontSize", "Font/Image Size: Adjust size of text or image stamps")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("AddStampRequest.help.previewTip", "Preview Tip"),
|
||||
description: t("AddStampRequest.help.previewTipDesc", "Use the preview to see how your stamp will look before applying. For image stamps, you can drag to position or use the quick grid.")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useAutoSplitPDFTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("autoSplitPDF.help.title", "QR Code Auto-Split Help")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("autoSplitPDF.help.overview", "Overview"),
|
||||
description: t("autoSplitPDF.help.overview", "This tool automatically splits scanned PDFs using special QR code divider pages. Perfect for batch scanning multiple documents.")
|
||||
},
|
||||
{
|
||||
title: t("autoSplitPDF.help.howItWorks", "How It Works"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("autoSplitPDF.help.step1", "1. Download and print the QR code divider page (black & white is fine)"),
|
||||
t("autoSplitPDF.help.step2", "2. Place divider pages between your documents"),
|
||||
t("autoSplitPDF.help.step3", "3. Scan all documents in one batch (dividers included)"),
|
||||
t("autoSplitPDF.help.step4", "4. Upload to Stirling-PDF - documents are automatically separated and dividers removed")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("autoSplitPDF.help.duplexMode", "Duplex Mode (Double-Sided Scanning)"),
|
||||
description: t("autoSplitPDF.help.duplexDesc", "Enable 'Duplex Mode' when scanning double-sided documents. This automatically skips the back sides of divider pages, preventing blank pages in your output."),
|
||||
bullets: [
|
||||
t("autoSplitPDF.help.duplexExample", "Example: With duplex ON, if you scan a divider followed by a 2-page document, the tool knows the divider's back is blank and skips it.")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("autoSplitPDF.help.qrCodes", "Valid QR Codes"),
|
||||
description: t("autoSplitPDF.help.qrDesc", "Only QR codes from Stirling-PDF divider pages work. These contain specific URLs: github.com/Stirling-Tools/Stirling-PDF, github.com/Frooodle/Stirling-PDF, or stirlingpdf.com")
|
||||
},
|
||||
{
|
||||
title: t("autoSplitPDF.help.useCases", "Best Use Cases"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("autoSplitPDF.help.useCase1", "Batch scanning multiple contracts, forms, or reports"),
|
||||
t("autoSplitPDF.help.useCase2", "Digitizing physical file folders"),
|
||||
t("autoSplitPDF.help.useCase3", "Scanning stacks of invoices or receipts"),
|
||||
t("autoSplitPDF.help.useCase4", "Processing mail or paperwork in bulk")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("autoSplitPDF.help.tips", "Tips"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("autoSplitPDF.help.tip1", "Print dividers once, reuse them for all your scanning sessions"),
|
||||
t("autoSplitPDF.help.tip2", "Dividers work in black & white - no need for color printing"),
|
||||
t("autoSplitPDF.help.tip3", "Make sure QR codes are clearly visible (not crumpled or dirty)"),
|
||||
t("autoSplitPDF.help.tip4", "For best results, use the same paper weight for dividers as your documents")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -6,31 +6,56 @@ export const useCompressTips = (): TooltipContent => {
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("compress.tooltip.header.title", "Compress Settings Overview")
|
||||
title: t("compress.help.title", "PDF Compression Guide")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("compress.tooltip.description.title", "Description"),
|
||||
description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.")
|
||||
title: t("compress.help.overview", "Overview"),
|
||||
description: t("compress.help.overview", "Reduce PDF file size while balancing quality. Uses qpdf for compression and optimization.")
|
||||
},
|
||||
{
|
||||
title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"),
|
||||
description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."),
|
||||
title: t("compress.help.methods", "Compression Methods"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"),
|
||||
t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size")
|
||||
t("compress.help.qualityMethod", "Quality: Choose compression strength (1-9). Lower preserves quality, higher reduces size more aggressively. Recommended: 3-5 for most documents."),
|
||||
t("compress.help.filesizeMethod", "File Size: Enter target size - tool automatically adjusts quality to reach it. Best when you have size limits (email attachments, etc.).")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("compress.tooltip.grayscale.title", "Grayscale"),
|
||||
description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.")
|
||||
title: t("compress.help.options", "Additional Options"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("compress.help.grayscale", "Grayscale: Converts all colors to black & white. Dramatically reduces size for color-heavy documents. Best for: Text documents, reports, forms."),
|
||||
t("compress.help.lineArt", "Line Art: Maximum compression. Converts to high-contrast black & white (requires ImageMagick). Best for: Text-only documents, technical drawings. NOT recommended for photos.")
|
||||
]
|
||||
},
|
||||
{
|
||||
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."
|
||||
)
|
||||
title: t("compress.help.qualityLevels", "Quality Level Guide"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("compress.help.level1to3", "1-3: High quality. Minimal compression. Use for important documents, presentations, or photos."),
|
||||
t("compress.help.level4to6", "4-6: Balanced. Noticeable compression with acceptable quality. Good for general use."),
|
||||
t("compress.help.level7to9", "7-9: Maximum compression. Lower quality but smallest files. Use for drafts, internal documents.")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("compress.help.tips", "Tips"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("compress.help.tip1", "Start with quality level 3-4 and increase if more compression needed"),
|
||||
t("compress.help.tip2", "Grayscale option works well for scanned documents without photos"),
|
||||
t("compress.help.tip3", "Line art is extreme - preview results before using on final documents"),
|
||||
t("compress.help.tip4", "File size method is convenient but may produce varying quality across pages")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("compress.help.expectations", "Size Reduction Expectations"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("compress.help.typical", "Typical: 20-40% reduction for standard PDFs"),
|
||||
t("compress.help.images", "Image-heavy: 50-70% reduction with grayscale"),
|
||||
t("compress.help.extreme", "Line art: 80-95% reduction (text becomes images)")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -6,29 +6,56 @@ export const useFlattenTips = (): TooltipContent => {
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("flatten.tooltip.header.title", "About Flattening PDFs")
|
||||
title: t("flatten.help.title", "Flatten PDF Guide")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("flatten.tooltip.description.title", "What does flattening do?"),
|
||||
description: t("flatten.tooltip.description.text", "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere."),
|
||||
title: t("flatten.help.overview", "Overview"),
|
||||
description: t("flatten.help.overview", "Flattening converts interactive PDF elements into static content. Makes forms non-editable and removes interactivity.")
|
||||
},
|
||||
{
|
||||
title: t("flatten.help.whatGetsFlattened", "What Gets Flattened"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("flatten.tooltip.description.bullet1", "Text boxes become regular text (can't be edited)"),
|
||||
t("flatten.tooltip.description.bullet2", "Checkboxes and buttons become pictures"),
|
||||
t("flatten.tooltip.description.bullet3", "Great for final versions you don't want changed"),
|
||||
t("flatten.tooltip.description.bullet4", "Ensures consistent appearance across all devices")
|
||||
t("flatten.help.fullFlatten", "Full Flatten (default): Text fields, checkboxes, radio buttons, dropdowns, buttons, annotations, and all interactive elements become static images/text."),
|
||||
t("flatten.help.formsOnly", "Forms Only: Only form fields become static. Links, bookmarks, comments, and annotations remain interactive.")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("flatten.tooltip.formsOnly.title", "What does 'Flatten only forms' mean?"),
|
||||
description: t("flatten.tooltip.formsOnly.text", "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments."),
|
||||
title: t("flatten.help.whenToFlatten", "When to Flatten"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("flatten.tooltip.formsOnly.bullet1", "Forms become non-editable"),
|
||||
t("flatten.tooltip.formsOnly.bullet2", "Links still work when clicked"),
|
||||
t("flatten.tooltip.formsOnly.bullet3", "Comments and notes remain visible"),
|
||||
t("flatten.tooltip.formsOnly.bullet4", "Bookmarks still help you navigate")
|
||||
t("flatten.help.useCase1", "Completed forms: After filling out a form, flatten to prevent further changes"),
|
||||
t("flatten.help.useCase2", "Final documents: Create locked versions for record-keeping or distribution"),
|
||||
t("flatten.help.useCase3", "Watermarked docs: After adding watermarks, flatten to prevent removal"),
|
||||
t("flatten.help.useCase4", "Pre-printed forms: Convert fillable PDFs to printable forms with visible fields"),
|
||||
t("flatten.help.useCase5", "Consistency: Ensure PDF looks identical across all viewers and devices")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("flatten.help.whatStaysInteractive", "What Stays Interactive (Forms Only mode)"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("flatten.help.interactive1", "Hyperlinks and web links remain clickable"),
|
||||
t("flatten.help.interactive2", "Bookmarks/table of contents for navigation"),
|
||||
t("flatten.help.interactive3", "Comments and annotations remain visible and editable"),
|
||||
t("flatten.help.interactive4", "Document outline and layers")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("flatten.help.important", "Important Notes"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("flatten.help.note1", "Flattening is permanent - cannot be reversed. Keep original if you need to edit later."),
|
||||
t("flatten.help.note2", "File size may increase slightly as form elements become images"),
|
||||
t("flatten.help.note3", "Flattened forms cannot be un-flattened - the form data is permanently merged"),
|
||||
t("flatten.help.note4", "Digital signatures may be invalidated by flattening")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("flatten.help.alternatives", "Alternative to Flattening"),
|
||||
description: t("flatten.help.altPermissions", "If you only want to prevent editing, consider using 'Change Permissions' instead of flattening. This keeps the PDF interactive but locked.")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,16 +6,16 @@ export const useRedactModeTips = (): TooltipContent => {
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("redact.tooltip.mode.header.title", "Redaction Method")
|
||||
title: t("redact.help.modeTitle", "Redaction Method")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("redact.tooltip.mode.automatic.title", "Automatic Redaction"),
|
||||
description: t("redact.tooltip.mode.automatic.text", "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers.")
|
||||
title: t("redact.help.overview", "Overview"),
|
||||
description: t("redact.help.overviewDesc", "Redaction permanently removes sensitive content from PDFs. Text behind redaction boxes is completely removed, not just covered. Metadata is also cleaned automatically.")
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.mode.manual.title", "Manual Redaction"),
|
||||
description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)")
|
||||
title: t("redact.help.automaticMode", "Automatic Redaction"),
|
||||
description: t("redact.help.automaticDesc", "Search for specific words, phrases, or patterns throughout the document and automatically redact all matches. Supports text search and regular expressions.")
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -26,21 +26,38 @@ export const useRedactWordsTips = (): TooltipContent => {
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("redact.tooltip.words.header.title", "Words to Redact")
|
||||
title: t("redact.help.wordsTitle", "Words & Patterns to Redact")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("redact.tooltip.words.description.title", "Text Matching"),
|
||||
description: t("redact.tooltip.words.description.text", "Enter words or phrases to find and redact in your document. Each word will be searched for separately."),
|
||||
title: t("redact.help.enterWords", "Enter Text to Redact"),
|
||||
description: t("redact.help.enterWordsDesc", "Type the words or phrases you want to remove from the document. Each word/phrase will be searched and redacted automatically."),
|
||||
bullets: [
|
||||
t("redact.tooltip.words.bullet1", "Add one word at a time"),
|
||||
t("redact.tooltip.words.bullet2", "Press Enter or click 'Add Another' to add"),
|
||||
t("redact.tooltip.words.bullet3", "Click × to remove words")
|
||||
t("redact.help.enterWordsBullet1", "Enter one or more words separated by commas"),
|
||||
t("redact.help.enterWordsBullet2", "Case sensitive by default"),
|
||||
t("redact.help.enterWordsBullet3", "Use whole word search to avoid partial matches")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.words.examples.title", "Common Examples"),
|
||||
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
|
||||
title: t("redact.help.wholeWordMode", "Whole Word Search"),
|
||||
description: t("redact.help.wholeWordDesc", "Enable 'Whole Word Search' to match only complete words. Example: 'John' won't match 'Johnson' when enabled. Useful for names.")
|
||||
},
|
||||
{
|
||||
title: t("redact.help.regexMode", "Regular Expression (Regex) Mode"),
|
||||
description: t("redact.help.regexDesc", "Enable 'Use Regex' to use pattern-based matching for complex searches. Powerful for finding multiple instances of similar data.")
|
||||
},
|
||||
{
|
||||
title: t("redact.help.regexExamples", "Common Regex Patterns"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("redact.help.regexSSN", "Social Security Number: \\d{3}-\\d{2}-\\d{4} (matches XXX-XX-XXXX)"),
|
||||
t("redact.help.regexPhone", "Phone Number: \\(?\\d{3}\\)?[-.]?\\d{3}[-.]?\\d{4} (matches various phone formats)"),
|
||||
t("redact.help.regexEmail", "Email Address: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,} (matches email addresses)"),
|
||||
t("redact.help.regexCreditCard", "Credit Card: \\d{4}[- ]?\\d{4}[- ]?\\d{4}[- ]?\\d{4} (matches card numbers)"),
|
||||
t("redact.help.regexDate", "Date (YYYY-MM-DD): \\d{4}-\\d{2}-\\d{2}"),
|
||||
t("redact.help.regexZipCode", "US ZIP Code: \\d{5}(-\\d{4})? (matches 12345 or 12345-6789)"),
|
||||
t("redact.help.regexIPAddress", "IP Address: \\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -51,28 +68,34 @@ export const useRedactAdvancedTips = (): TooltipContent => {
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings")
|
||||
title: t("redact.help.advancedTitle", "Advanced Settings")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"),
|
||||
description: t("redact.tooltip.advanced.color.text", "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."),
|
||||
title: t("redact.help.customColor", "Custom Redaction Color"),
|
||||
description: t("redact.help.customColorDesc", "Choose the color of the redaction boxes. Default is black for maximum privacy.")
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.advanced.regex.title", "Use Regex"),
|
||||
description: t("redact.tooltip.advanced.regex.text", "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns."),
|
||||
title: t("redact.help.padding", "Custom Padding"),
|
||||
description: t("redact.help.paddingDesc", "Add extra space around redaction boxes to ensure complete coverage. Useful when fonts vary in size or style.")
|
||||
},
|
||||
{
|
||||
title: t("redact.help.securityFeatures", "Security Features"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("redact.tooltip.advanced.regex.bullet1", "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format"),
|
||||
t("redact.tooltip.advanced.regex.bullet2", "Use with caution - test thoroughly")
|
||||
t("redact.help.securityConvert", "Convert to PDF-Image: Converts the entire PDF to images after redaction. This ensures text is truly unrecoverable. Note: Increases file size and makes text non-selectable."),
|
||||
t("redact.help.securityMetadata", "Automatic Metadata Cleaning: Automatically removes author, subject, keywords, and XMP metadata for privacy.")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.advanced.wholeWord.title", "Whole Word Search"),
|
||||
description: t("redact.tooltip.advanced.wholeWord.text", "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled.")
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.advanced.convert.title", "Convert to PDF-Image"),
|
||||
description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.")
|
||||
title: t("redact.help.tips", "Best Practices"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t("redact.help.tip1", "Test First: Test your regex patterns on a copy before using on important documents."),
|
||||
t("redact.help.tip2", "Review Results: Always review redacted documents to ensure all sensitive information is removed."),
|
||||
t("redact.help.tip3", "Use Convert to Image: For highly sensitive documents, enable 'Convert to PDF-Image' to ensure complete removal."),
|
||||
t("redact.help.tip4", "Padding: Add custom padding to ensure redaction boxes fully cover text (useful for different font sizes).")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -6,49 +6,43 @@ export const useScannerImageSplitTips = (): TooltipContent => {
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('scannerImageSplit.tooltip.title', 'Photo Splitter')
|
||||
title: t("scannerImageSplit.help.title", "Advanced OpenCV Parameters")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.whatThisDoes', 'What this does'),
|
||||
description: t('scannerImageSplit.tooltip.whatThisDoesDesc',
|
||||
'Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.'
|
||||
)
|
||||
title: t("scannerImageSplit.help.overview", "Overview"),
|
||||
description: t("scannerImageSplit.help.overview", "This tool uses OpenCV (computer vision library) to automatically detect individual photos on scanned pages.")
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.whenToUse', 'When to use'),
|
||||
title: t("scannerImageSplit.help.angleThreshold", "Angle Threshold (Default: 5)"),
|
||||
description: t("scannerImageSplit.help.angleThresholdDesc", "Rotation angle in degrees needed before auto-straightening a photo. Lower values (1-3) straighten more aggressively, higher values (10-15) only straighten very tilted photos.")
|
||||
},
|
||||
{
|
||||
title: t("scannerImageSplit.help.tolerance", "Tolerance (Default: 20)"),
|
||||
description: t("scannerImageSplit.help.toleranceDesc", "How closely a color must match the page background to count as background. Higher values (30-50) detect photos more easily but may include background noise. Lower values (10-15) are stricter.")
|
||||
},
|
||||
{
|
||||
title: t("scannerImageSplit.help.minArea", "Minimum Area (Default: 8000)"),
|
||||
description: t("scannerImageSplit.help.minAreaDesc", "Smallest photo size in pixels² to keep. Increase to 15,000-20,000 to filter out small fragments. Decrease to 3000-5000 to detect smaller photos.")
|
||||
},
|
||||
{
|
||||
title: t("scannerImageSplit.help.minContourArea", "Minimum Contour Area (Default: 500)"),
|
||||
description: t("scannerImageSplit.help.minContourAreaDesc", "Smallest edge/shape size when detecting photo boundaries. Increase to 1000-2000 to filter out dust and specks. Lower values detect finer edges.")
|
||||
},
|
||||
{
|
||||
title: t("scannerImageSplit.help.borderSize", "Border Size (Default: 1)"),
|
||||
description: t("scannerImageSplit.help.borderSizeDesc", "Extra padding in pixels around each extracted photo. Increase to 5-10 to avoid cutting edges. Set to 0 for no padding.")
|
||||
},
|
||||
{
|
||||
title: t("scannerImageSplit.help.recommendedSettings", "Recommended Settings"),
|
||||
description: "",
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.useCase1', 'Scan whole album pages in one go'),
|
||||
t('scannerImageSplit.tooltip.useCase2', 'Split flatbed batches into separate files'),
|
||||
t('scannerImageSplit.tooltip.useCase3', 'Break collages into individual photos'),
|
||||
t('scannerImageSplit.tooltip.useCase4', 'Pull photos from documents')
|
||||
t("scannerImageSplit.help.normalScans", "Normal photo scans: Use defaults (Angle: 5, Tolerance: 20, Min Area: 8000)"),
|
||||
t("scannerImageSplit.help.highQuality", "High-quality photos on clean background: Tolerance 15, Min Area 10000, Border 3"),
|
||||
t("scannerImageSplit.help.noisyScans", "Noisy/dirty scans: Tolerance 30, Min Contour Area 1500, Border 5"),
|
||||
t("scannerImageSplit.help.smallPhotos", "Small photos (ID cards, stamps): Min Area 3000, Border 2")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.quickFixes', 'Quick fixes'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.problem1', 'Photos not detected → increase Tolerance to 30–50'),
|
||||
t('scannerImageSplit.tooltip.problem2', 'Too many false detections → increase Minimum Area to 15,000–20,000'),
|
||||
t('scannerImageSplit.tooltip.problem3', 'Crops are too tight → increase Border Size to 5–10'),
|
||||
t('scannerImageSplit.tooltip.problem4', 'Tilted photos not straightened → lower Angle Threshold to ~5°'),
|
||||
t('scannerImageSplit.tooltip.problem5', 'Dust/noise boxes → increase Minimum Contour Area to 1000–2000')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.setupTips', 'Setup tips'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.tip1', 'Use a plain, light background'),
|
||||
t('scannerImageSplit.tooltip.tip2', 'Leave a small gap (≈1 cm) between photos'),
|
||||
t('scannerImageSplit.tooltip.tip3', 'Scan at 300–600 DPI'),
|
||||
t('scannerImageSplit.tooltip.tip4', 'Clean the scanner glass')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.headsUp', 'Heads-up'),
|
||||
description: t('scannerImageSplit.tooltip.headsUpDesc',
|
||||
'Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy—try a lighter or darker background and leave more space.'
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -56,6 +56,9 @@ const EmbedPdfViewerContent = ({
|
||||
exportActions,
|
||||
} = useViewer();
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const rotationState = getRotationState();
|
||||
|
||||
@@ -67,13 +70,8 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature and annotation contexts
|
||||
const { signatureApiRef, annotationApiRef, 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 signature context
|
||||
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
@@ -87,8 +85,8 @@ const EmbedPdfViewerContent = ({
|
||||
|
||||
// Check if we're in an annotation tool
|
||||
const { selectedTool } = useNavigationState();
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image, Annotate)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage' || selectedTool === 'annotate';
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
|
||||
// Sync isAnnotationMode in ViewerContext with current tool
|
||||
useEffect(() => {
|
||||
@@ -227,31 +225,6 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
@@ -259,28 +232,39 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
console.log('[Viewer] Registering unsaved changes checker');
|
||||
registerUnsavedChangesChecker(checkForChanges);
|
||||
|
||||
return () => {
|
||||
console.log('[Viewer] Unregistering unsaved changes checker');
|
||||
unregisterUnsavedChangesChecker();
|
||||
};
|
||||
}, [previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
|
||||
// Apply changes - save annotations to new file version
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!currentFile || activeFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
console.log('[Viewer] Applying changes - exporting PDF with annotations');
|
||||
|
||||
// Step 1: Export PDF with annotations using EmbedPDF
|
||||
const arrayBuffer = await exportActions.saveAsCopy();
|
||||
if (!arrayBuffer) {
|
||||
throw new Error('Failed to export PDF');
|
||||
}
|
||||
|
||||
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
|
||||
|
||||
// Step 2: Convert ArrayBuffer to File
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const filename = currentFile.name || 'document.pdf';
|
||||
@@ -295,29 +279,12 @@ const EmbedPdfViewerContent = ({
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
// Mark annotations as saved so navigation away from the viewer is allowed.
|
||||
hasAnnotationChangesRef.current = false;
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
}
|
||||
}, [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 totalRightMargin =
|
||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
||||
@@ -373,7 +340,6 @@ const EmbedPdfViewerContent = ({
|
||||
enableAnnotations={isAnnotationMode}
|
||||
showBakedAnnotations={isAnnotationsVisible}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
annotationApiRef={annotationApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
// 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 { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge';
|
||||
import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
|
||||
import { AnnotationAPIBridge } from '@app/components/viewer/AnnotationAPIBridge';
|
||||
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 { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||
import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
|
||||
@@ -56,11 +55,10 @@ interface LocalEmbedPDFProps {
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
annotationApiRef?: React.RefObject<AnnotationAPI>;
|
||||
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, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
@@ -125,8 +123,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
selectAfterCreate: true,
|
||||
}),
|
||||
|
||||
// Register pan plugin (depends on Viewport, InteractionManager) - keep disabled to prevent drag panning
|
||||
createPluginRegistration(PanPluginPackage, {}),
|
||||
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||
}),
|
||||
|
||||
// Register zoom plugin with configuration
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
@@ -252,315 +252,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
if (!annotationApi) return;
|
||||
|
||||
if (enableAnnotations) {
|
||||
const ensureTool = (tool: any) => {
|
||||
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({
|
||||
annotationApi.addTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
@@ -570,7 +262,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
},
|
||||
});
|
||||
|
||||
ensureTool({
|
||||
annotationApi.addTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
@@ -618,7 +310,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
|
||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<BookmarkAPIBridge />
|
||||
|
||||
@@ -104,20 +104,12 @@ const createTextStampImage = (
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = config.textAlign || 'left';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const horizontalPadding = paddingX;
|
||||
const verticalCenter = naturalHeight / 2;
|
||||
|
||||
let xPosition = horizontalPadding;
|
||||
if (config.textAlign === 'center') {
|
||||
xPosition = naturalWidth / 2;
|
||||
} else if (config.textAlign === 'right') {
|
||||
xPosition = naturalWidth - horizontalPadding;
|
||||
}
|
||||
|
||||
ctx.fillText(text, xPosition, verticalCenter);
|
||||
ctx.fillText(text, horizontalPadding, verticalCenter);
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
@@ -207,21 +199,12 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
}
|
||||
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations
|
||||
useEffect(() => {
|
||||
// Always enable delete key when we have annotation API and are in sign mode
|
||||
if (!annotationApi || (isPlacementMode === undefined)) return;
|
||||
|
||||
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') {
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
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 { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
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() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -19,32 +16,6 @@ export function useViewerRightRailButtons() {
|
||||
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
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
|
||||
const searchLabel = t('rightRail.search', 'Search PDF');
|
||||
@@ -54,11 +25,9 @@ export function useViewerRightRailButtons() {
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||
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 buttons: RightRailButtonWithAction[] = [
|
||||
return [
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: searchLabel,
|
||||
@@ -178,36 +147,6 @@ export function useViewerRightRailButtons() {
|
||||
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',
|
||||
section: 'top' as const,
|
||||
@@ -215,30 +154,9 @@ export function useViewerRightRailButtons() {
|
||||
render: ({ disabled }) => (
|
||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||
)
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
// 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,
|
||||
]);
|
||||
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, printLabel, tooltipPosition]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,6 @@ export interface SignatureAPI {
|
||||
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 {
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
@@ -34,50 +23,3 @@ export interface HistoryAPI {
|
||||
canRedo: () => boolean;
|
||||
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,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;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
|
||||
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
|
||||
interface SignatureState {
|
||||
@@ -34,7 +34,6 @@ interface SignatureActions {
|
||||
// Combined context interface
|
||||
interface SignatureContextValue extends SignatureState, SignatureActions {
|
||||
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
||||
historyApiRef: React.RefObject<HistoryAPI | null>;
|
||||
}
|
||||
|
||||
@@ -53,7 +52,6 @@ const initialState: SignatureState = {
|
||||
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<SignatureState>(initialState);
|
||||
const signatureApiRef = useRef<SignatureAPI>(null);
|
||||
const annotationApiRef = useRef<AnnotationAPI>(null);
|
||||
const historyApiRef = useRef<HistoryAPI>(null);
|
||||
const imageDataStore = useRef<Map<string, string>>(new Map());
|
||||
|
||||
@@ -159,7 +157,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
|
||||
const contextValue: SignatureContextValue = {
|
||||
...state,
|
||||
signatureApiRef,
|
||||
annotationApiRef,
|
||||
historyApiRef,
|
||||
setSignatureConfig,
|
||||
setPlacementMode,
|
||||
|
||||
@@ -51,7 +51,6 @@ import Crop from "@app/tools/Crop";
|
||||
import Sign from "@app/tools/Sign";
|
||||
import AddText from "@app/tools/AddText";
|
||||
import AddImage from "@app/tools/AddImage";
|
||||
import Annotate from "@app/tools/Annotate";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
|
||||
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||
@@ -247,19 +246,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
synonyms: getSynonyms(t, 'addImage'),
|
||||
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
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface SignParameters {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
textColor?: string;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export const DEFAULT_PARAMETERS: SignParameters = {
|
||||
@@ -29,7 +28,6 @@ export const DEFAULT_PARAMETERS: SignParameters = {
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 16,
|
||||
textColor: '#000000',
|
||||
textAlign: 'left',
|
||||
};
|
||||
|
||||
const validateSignParameters = (parameters: SignParameters): boolean => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface AppConfig {
|
||||
frontendUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the configured frontend URL from backend app-config.
|
||||
* Falls back to window.location.origin if not configured.
|
||||
*/
|
||||
export const useFrontendUrl = (): string => {
|
||||
const [frontendUrl, setFrontendUrl] = useState<string>(window.location.origin);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFrontendUrl = async () => {
|
||||
try {
|
||||
const response = await apiClient.get<AppConfig>('/api/v1/app-config');
|
||||
const configuredUrl = response.data.frontendUrl;
|
||||
|
||||
// Use configured URL if not empty, otherwise keep window.location.origin
|
||||
if (configuredUrl && configuredUrl.trim() !== '') {
|
||||
setFrontendUrl(configuredUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch app config, using window.location.origin:', error);
|
||||
// Keep the default window.location.origin on error
|
||||
}
|
||||
};
|
||||
|
||||
fetchFrontendUrl();
|
||||
}, []);
|
||||
|
||||
return frontendUrl;
|
||||
};
|
||||
@@ -0,0 +1,897 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Button, Stack, Text, Group, Alert, Tabs, Progress, Switch, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
|
||||
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
|
||||
import PhotoCameraRoundedIcon from '@mui/icons-material/PhotoCameraRounded';
|
||||
import UploadRoundedIcon from '@mui/icons-material/UploadRounded';
|
||||
import AddPhotoAlternateRoundedIcon from '@mui/icons-material/AddPhotoAlternateRounded';
|
||||
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
|
||||
|
||||
// jscanify is loaded via script tag in index.html as a global
|
||||
declare global {
|
||||
interface Window {
|
||||
jscanify: any;
|
||||
cv: any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MobileScannerPage
|
||||
*
|
||||
* Mobile-friendly page for capturing photos and uploading them to the backend server.
|
||||
* Accessed by scanning QR code from desktop.
|
||||
*/
|
||||
export default function MobileScannerPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const sessionId = searchParams.get('session');
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const brandIconSrc = useLogoPath();
|
||||
const { wordmark } = useLogoAssets();
|
||||
const brandTextSrc = colorScheme === 'dark' ? wordmark.white : wordmark.black;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string | null>('camera');
|
||||
const [capturedImages, setCapturedImages] = useState<string[]>([]);
|
||||
const [currentPreview, setCurrentPreview] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const [autoEnhance, setAutoEnhance] = useState(true);
|
||||
const [showLiveDetection, setShowLiveDetection] = useState(true); // On by default with adaptive performance
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [openCvReady, setOpenCvReady] = useState(false);
|
||||
const [torchEnabled, setTorchEnabled] = useState(false);
|
||||
const [torchSupported, setTorchSupported] = useState(false);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const highlightCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const scannerRef = useRef<any>(null);
|
||||
const highlightIntervalRef = useRef<number | null>(null);
|
||||
|
||||
// Detection resolution - extremely low for mobile performance
|
||||
const DETECTION_WIDTH = 160; // Ultra-low for real-time mobile detection
|
||||
|
||||
// Initialize jscanify scanner and wait for OpenCV (loaded via script tags in index.html)
|
||||
useEffect(() => {
|
||||
let retryCount = 0;
|
||||
const MAX_RETRIES = 50; // 5 seconds max wait
|
||||
|
||||
const initScanner = () => {
|
||||
// Check if both OpenCV and jscanify are loaded
|
||||
if (!(window as any).cv || !(window as any).cv.Mat) {
|
||||
retryCount++;
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
if (retryCount % 10 === 1) {
|
||||
console.log(`[${retryCount}/${MAX_RETRIES}] Waiting for OpenCV to load...`);
|
||||
}
|
||||
setTimeout(initScanner, 100);
|
||||
} else {
|
||||
console.error('OpenCV failed to load after 5 seconds. Check that /vendor/jscanify/opencv.js is accessible.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.jscanify) {
|
||||
retryCount++;
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
if (retryCount % 10 === 1) {
|
||||
console.log(`[${retryCount}/${MAX_RETRIES}] Waiting for jscanify to load...`);
|
||||
}
|
||||
setTimeout(initScanner, 100);
|
||||
} else {
|
||||
console.error('jscanify failed to load after 5 seconds. Check that /vendor/jscanify/jscanify.js is accessible.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
scannerRef.current = new window.jscanify();
|
||||
setOpenCvReady(true);
|
||||
console.log('✓ jscanify initialized with OpenCV');
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize jscanify:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Start initialization
|
||||
initScanner();
|
||||
}, []);
|
||||
|
||||
// Initialize camera
|
||||
useEffect(() => {
|
||||
if (activeTab === 'camera' && !cameraError && !currentPreview) {
|
||||
// Check if mediaDevices API is available (requires HTTPS or localhost)
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
console.error('MediaDevices API not available - requires HTTPS or localhost');
|
||||
setCameraError(
|
||||
t(
|
||||
'mobileScanner.httpsRequired',
|
||||
'Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost.'
|
||||
)
|
||||
);
|
||||
setActiveTab('file');
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
// Request 1080p - good quality without going overboard
|
||||
width: { ideal: 1920, max: 1920 },
|
||||
height: { ideal: 1080, max: 1080 },
|
||||
},
|
||||
audio: false,
|
||||
})
|
||||
.then(async (stream) => {
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
|
||||
// Log actual resolution we got
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
const settings = videoTrack.getSettings();
|
||||
console.log('Camera resolution:', settings.width, 'x', settings.height);
|
||||
|
||||
// Configure camera capabilities for document scanning
|
||||
try {
|
||||
const capabilities = videoTrack.getCapabilities() as any; // Cast to any for experimental camera APIs
|
||||
const constraints: any = { advanced: [] };
|
||||
|
||||
// 1. Enable continuous autofocus
|
||||
if (capabilities.focusMode && capabilities.focusMode.includes('continuous')) {
|
||||
constraints.advanced.push({ focusMode: 'continuous' });
|
||||
console.log('✓ Continuous autofocus enabled');
|
||||
}
|
||||
|
||||
// 2. Enable continuous auto-exposure for varying lighting
|
||||
if (capabilities.exposureMode && capabilities.exposureMode.includes('continuous')) {
|
||||
constraints.advanced.push({ exposureMode: 'continuous' });
|
||||
console.log('✓ Auto-exposure enabled');
|
||||
}
|
||||
|
||||
// 3. Check if torch/flashlight is supported
|
||||
if (capabilities.torch) {
|
||||
setTorchSupported(true);
|
||||
console.log('✓ Torch/flashlight available');
|
||||
}
|
||||
|
||||
// Apply all constraints
|
||||
if (constraints.advanced.length > 0) {
|
||||
await videoTrack.applyConstraints(constraints);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Could not configure camera features:', err);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Camera error:', err);
|
||||
setCameraError(t('mobileScanner.cameraAccessDenied', 'Camera access denied. Please enable camera access.'));
|
||||
// Auto-switch to file upload if camera fails
|
||||
setActiveTab('file');
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up stream when switching away from camera or showing preview
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
// Stop highlighting when camera is stopped
|
||||
if (highlightIntervalRef.current) {
|
||||
clearInterval(highlightIntervalRef.current);
|
||||
highlightIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeTab, cameraError, currentPreview, t]);
|
||||
|
||||
// Real-time document highlighting on camera feed
|
||||
useEffect(() => {
|
||||
console.log(`[Mobile Scanner] Effect triggered: activeTab=${activeTab}, showLiveDetection=${showLiveDetection}, openCvReady=${openCvReady}, currentPreview=${currentPreview}`);
|
||||
|
||||
if (activeTab === 'camera' && showLiveDetection && openCvReady && scannerRef.current && !currentPreview) {
|
||||
const startHighlighting = () => {
|
||||
if (!videoRef.current || !highlightCanvasRef.current) return;
|
||||
if (!videoRef.current.videoWidth || !videoRef.current.videoHeight) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
const highlightCanvas = highlightCanvasRef.current;
|
||||
|
||||
// Create low-res detection canvas with optimized context for frequent pixel reading
|
||||
const detectionCanvas = document.createElement('canvas');
|
||||
const detectionCtx = detectionCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!detectionCtx) return;
|
||||
|
||||
// Calculate scaled dimensions for detection (160px wide max)
|
||||
const scale = DETECTION_WIDTH / video.videoWidth;
|
||||
detectionCanvas.width = DETECTION_WIDTH;
|
||||
detectionCanvas.height = Math.round(video.videoHeight * scale);
|
||||
|
||||
// CRITICAL FIX: Make highlight canvas ALSO low-res (CSS will scale it visually)
|
||||
// Drawing to a 4K canvas is what was causing the lag!
|
||||
highlightCanvas.width = DETECTION_WIDTH;
|
||||
highlightCanvas.height = Math.round(video.videoHeight * scale);
|
||||
|
||||
console.log(`[Mobile Scanner] Video: ${video.videoWidth}x${video.videoHeight}`);
|
||||
console.log(`[Mobile Scanner] Detection: ${detectionCanvas.width}x${detectionCanvas.height} (${Math.round(scale * 100)}%)`);
|
||||
console.log(`[Mobile Scanner] Highlight canvas: ${highlightCanvas.width}x${highlightCanvas.height}`);
|
||||
console.log(`[Mobile Scanner] Starting interval at 1 FPS`);
|
||||
|
||||
// Set highlight canvas to match video for vector drawing
|
||||
highlightCanvas.width = video.videoWidth;
|
||||
highlightCanvas.height = video.videoHeight;
|
||||
const highlightCtx = highlightCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!highlightCtx) return;
|
||||
|
||||
// Use requestAnimationFrame with adaptive throttle based on device performance
|
||||
let frameCount = 0;
|
||||
const frameTimes: number[] = [];
|
||||
let lastDetectionTime = 0;
|
||||
let detectionInterval = 333; // Start at 3 FPS (333ms)
|
||||
const detectionTimings: number[] = []; // Track last 10 detection times
|
||||
const MAX_TIMINGS = 10;
|
||||
|
||||
const runDetection = () => {
|
||||
const now = performance.now();
|
||||
|
||||
// Only run detection every second
|
||||
if (now - lastDetectionTime >= detectionInterval) {
|
||||
lastDetectionTime = now;
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Step 1: Copy video to low-res detection canvas
|
||||
const copyStart = performance.now();
|
||||
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
|
||||
const copyTime = performance.now() - copyStart;
|
||||
|
||||
// Step 2: Run detection on low-res to get corner points
|
||||
const detectionStart = performance.now();
|
||||
const mat = (window as any).cv.imread(detectionCanvas);
|
||||
const contour = scannerRef.current.findPaperContour(mat);
|
||||
let corners = null;
|
||||
|
||||
if (contour) {
|
||||
// Validate contour area (reject if too small or too large)
|
||||
const contourArea = (window as any).cv.contourArea(contour);
|
||||
const frameArea = detectionCanvas.width * detectionCanvas.height;
|
||||
const areaPercent = (contourArea / frameArea) * 100;
|
||||
|
||||
// Only accept if contour is 15-85% of frame (filters out noise and frame edges)
|
||||
if (areaPercent >= 15 && areaPercent <= 85) {
|
||||
corners = scannerRef.current.getCornerPoints(contour);
|
||||
}
|
||||
}
|
||||
mat.delete();
|
||||
const detectionTime = performance.now() - detectionStart;
|
||||
|
||||
// Step 3: Draw ONLY the corner lines on full-res canvas (super fast!)
|
||||
const drawStart = performance.now();
|
||||
highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height);
|
||||
|
||||
// Validate we have all 4 corners (no triangles!)
|
||||
if (
|
||||
corners &&
|
||||
corners.topLeftCorner &&
|
||||
corners.topRightCorner &&
|
||||
corners.bottomLeftCorner &&
|
||||
corners.bottomRightCorner
|
||||
) {
|
||||
// Scale corner points from low-res to full-res
|
||||
const scaleFactor = video.videoWidth / detectionCanvas.width;
|
||||
const tl = { x: corners.topLeftCorner.x * scaleFactor, y: corners.topLeftCorner.y * scaleFactor };
|
||||
const tr = { x: corners.topRightCorner.x * scaleFactor, y: corners.topRightCorner.y * scaleFactor };
|
||||
const br = { x: corners.bottomRightCorner.x * scaleFactor, y: corners.bottomRightCorner.y * scaleFactor };
|
||||
const bl = { x: corners.bottomLeftCorner.x * scaleFactor, y: corners.bottomLeftCorner.y * scaleFactor };
|
||||
|
||||
// Validation 1: Minimum distance between corners
|
||||
const minDistance = 50;
|
||||
const distances = [
|
||||
Math.hypot(tr.x - tl.x, tr.y - tl.y),
|
||||
Math.hypot(br.x - tr.x, br.y - tr.y),
|
||||
Math.hypot(bl.x - br.x, bl.y - br.y),
|
||||
Math.hypot(tl.x - bl.x, tl.y - bl.y),
|
||||
];
|
||||
|
||||
const allCornersSpaced = distances.every((d) => d > minDistance);
|
||||
|
||||
// Validation 2: Aspect ratio (documents are ~1.0 to 1.5 ratio)
|
||||
const width1 = Math.hypot(tr.x - tl.x, tr.y - tl.y);
|
||||
const width2 = Math.hypot(br.x - bl.x, br.y - bl.y);
|
||||
const height1 = Math.hypot(bl.x - tl.x, bl.y - tl.y);
|
||||
const height2 = Math.hypot(br.x - tr.x, br.y - tr.y);
|
||||
|
||||
const avgWidth = (width1 + width2) / 2;
|
||||
const avgHeight = (height1 + height2) / 2;
|
||||
const aspectRatio = Math.max(avgWidth, avgHeight) / Math.min(avgWidth, avgHeight);
|
||||
|
||||
// Accept aspect ratios from 1:1 (square) to 1:2 (elongated document)
|
||||
const goodAspectRatio = aspectRatio >= 1.0 && aspectRatio <= 2.0;
|
||||
|
||||
if (allCornersSpaced && goodAspectRatio) {
|
||||
// Draw lines connecting corners (vector graphics - super lightweight!)
|
||||
highlightCtx.strokeStyle = '#00FF00';
|
||||
highlightCtx.lineWidth = 4;
|
||||
highlightCtx.beginPath();
|
||||
highlightCtx.moveTo(tl.x, tl.y);
|
||||
highlightCtx.lineTo(tr.x, tr.y);
|
||||
highlightCtx.lineTo(br.x, br.y);
|
||||
highlightCtx.lineTo(bl.x, bl.y);
|
||||
highlightCtx.lineTo(tl.x, tl.y);
|
||||
highlightCtx.stroke();
|
||||
}
|
||||
}
|
||||
const drawTime = performance.now() - drawStart;
|
||||
|
||||
const totalTime = performance.now() - startTime;
|
||||
frameCount++;
|
||||
frameTimes.push(totalTime);
|
||||
|
||||
// Track detection timings for adaptive performance
|
||||
detectionTimings.push(totalTime);
|
||||
if (detectionTimings.length > MAX_TIMINGS) {
|
||||
detectionTimings.shift(); // Keep only last 10
|
||||
}
|
||||
|
||||
// Adaptive performance adjustment (after warmup period)
|
||||
if (frameCount > 5 && detectionTimings.length >= 5) {
|
||||
const avgTime = detectionTimings.reduce((a, b) => a + b, 0) / detectionTimings.length;
|
||||
|
||||
// Adjust detection interval based on average performance
|
||||
if (avgTime < 20) {
|
||||
// Very fast device: 5 FPS (200ms)
|
||||
detectionInterval = 200;
|
||||
} else if (avgTime < 40) {
|
||||
// Fast device: 3 FPS (333ms)
|
||||
detectionInterval = 333;
|
||||
} else if (avgTime < 80) {
|
||||
// Medium device: 2 FPS (500ms)
|
||||
detectionInterval = 500;
|
||||
} else {
|
||||
// Slower device: 1 FPS (1000ms)
|
||||
detectionInterval = 1000;
|
||||
}
|
||||
}
|
||||
|
||||
if (frameCount <= 10) {
|
||||
console.log(`[Mobile Scanner] Frame ${frameCount}: ${Math.round(totalTime)}ms total (copy: ${Math.round(copyTime)}ms, detect: ${Math.round(detectionTime)}ms, draw: ${Math.round(drawTime)}ms) - interval: ${detectionInterval}ms`);
|
||||
}
|
||||
|
||||
if (frameCount === 10) {
|
||||
const avg = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
|
||||
console.log(`[Mobile Scanner] Average of first 10 frames: ${Math.round(avg)}ms - Adaptive rate: ${Math.round(1000/detectionInterval)} FPS`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Mobile Scanner] Detection error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue animation loop
|
||||
highlightIntervalRef.current = requestAnimationFrame(runDetection);
|
||||
};
|
||||
|
||||
// Start the animation loop
|
||||
highlightIntervalRef.current = requestAnimationFrame(runDetection);
|
||||
};
|
||||
|
||||
// Wait for video to be ready
|
||||
if (videoRef.current && videoRef.current.readyState >= 2) {
|
||||
startHighlighting();
|
||||
} else if (videoRef.current) {
|
||||
videoRef.current.addEventListener('loadedmetadata', startHighlighting);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (highlightIntervalRef.current) {
|
||||
console.log('[Mobile Scanner] Stopping detection');
|
||||
cancelAnimationFrame(highlightIntervalRef.current);
|
||||
highlightIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [activeTab, showLiveDetection, openCvReady, currentPreview]);
|
||||
|
||||
const captureImage = useCallback(async () => {
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) return;
|
||||
|
||||
// Capture raw image from video at full resolution
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
let finalDataUrl: string;
|
||||
|
||||
// Apply jscanify processing if enabled and available
|
||||
if (autoEnhance && scannerRef.current && openCvReady) {
|
||||
try {
|
||||
// Create low-res canvas for detection (faster processing)
|
||||
const detectionCanvas = document.createElement('canvas');
|
||||
const detectionCtx = detectionCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!detectionCtx) throw new Error('Cannot create detection context');
|
||||
|
||||
const scale = DETECTION_WIDTH / video.videoWidth;
|
||||
detectionCanvas.width = DETECTION_WIDTH;
|
||||
detectionCanvas.height = Math.round(video.videoHeight * scale);
|
||||
|
||||
// Draw downscaled image for detection
|
||||
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
|
||||
|
||||
// Run detection on low-res image
|
||||
const mat = (window as any).cv.imread(detectionCanvas);
|
||||
const contour = scannerRef.current.findPaperContour(mat);
|
||||
|
||||
if (contour) {
|
||||
const cornerPoints = scannerRef.current.getCornerPoints(contour);
|
||||
|
||||
// Scale corner points back to full resolution
|
||||
if (cornerPoints) {
|
||||
const scaleFactor = 1 / scale;
|
||||
const scaledCorners = {
|
||||
topLeftCorner: { x: cornerPoints.topLeftCorner.x * scaleFactor, y: cornerPoints.topLeftCorner.y * scaleFactor },
|
||||
topRightCorner: { x: cornerPoints.topRightCorner.x * scaleFactor, y: cornerPoints.topRightCorner.y * scaleFactor },
|
||||
bottomLeftCorner: { x: cornerPoints.bottomLeftCorner.x * scaleFactor, y: cornerPoints.bottomLeftCorner.y * scaleFactor },
|
||||
bottomRightCorner: { x: cornerPoints.bottomRightCorner.x * scaleFactor, y: cornerPoints.bottomRightCorner.y * scaleFactor },
|
||||
};
|
||||
|
||||
// Use scaled corners for validation and extraction
|
||||
const { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } = scaledCorners;
|
||||
|
||||
// Validate corner points are reasonable (minimum distance in full-res)
|
||||
const minDistance = 100; // Minimum pixels between corners at full resolution
|
||||
const distances = [
|
||||
Math.hypot(topRightCorner.x - topLeftCorner.x, topRightCorner.y - topLeftCorner.y),
|
||||
Math.hypot(bottomRightCorner.x - topRightCorner.x, bottomRightCorner.y - topRightCorner.y),
|
||||
Math.hypot(bottomLeftCorner.x - bottomRightCorner.x, bottomLeftCorner.y - bottomRightCorner.y),
|
||||
Math.hypot(topLeftCorner.x - bottomLeftCorner.x, topLeftCorner.y - bottomLeftCorner.y),
|
||||
];
|
||||
|
||||
const isValidDetection = distances.every((d) => d > minDistance);
|
||||
|
||||
if (!isValidDetection) {
|
||||
console.warn('Detected corners are too close together, using original image');
|
||||
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Valid document detected at full resolution:', {
|
||||
corners: scaledCorners,
|
||||
distances: distances.map((d) => Math.round(d)),
|
||||
});
|
||||
|
||||
// Calculate width and height of the document
|
||||
const topWidth = Math.hypot(topRightCorner.x - topLeftCorner.x, topRightCorner.y - topLeftCorner.y);
|
||||
const bottomWidth = Math.hypot(bottomRightCorner.x - bottomLeftCorner.x, bottomRightCorner.y - bottomLeftCorner.y);
|
||||
const leftHeight = Math.hypot(bottomLeftCorner.x - topLeftCorner.x, bottomLeftCorner.y - topLeftCorner.y);
|
||||
const rightHeight = Math.hypot(bottomRightCorner.x - topRightCorner.x, bottomRightCorner.y - topRightCorner.y);
|
||||
|
||||
// Use average dimensions to maintain proper aspect ratio
|
||||
const docWidth = Math.round((topWidth + bottomWidth) / 2);
|
||||
const docHeight = Math.round((leftHeight + rightHeight) / 2);
|
||||
|
||||
// Extract paper from full-resolution canvas with scaled corner points
|
||||
const resultCanvas = scannerRef.current.extractPaper(canvas, docWidth, docHeight, scaledCorners);
|
||||
|
||||
// Use high quality JPEG compression to preserve image quality
|
||||
finalDataUrl = resultCanvas.toDataURL('image/jpeg', 0.95);
|
||||
} else {
|
||||
console.log('No corners detected, using original');
|
||||
mat.delete();
|
||||
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
||||
}
|
||||
} else {
|
||||
console.log('No contour detected, using original');
|
||||
mat.delete();
|
||||
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('jscanify processing failed, using original image:', err);
|
||||
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
||||
}
|
||||
} else {
|
||||
// Auto-enhance disabled or jscanify not available - use original at high quality
|
||||
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
|
||||
}
|
||||
|
||||
setCurrentPreview(finalDataUrl);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [autoEnhance, openCvReady]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
setCurrentPreview(event.target.result as string);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
|
||||
const addToBatch = useCallback(() => {
|
||||
if (currentPreview) {
|
||||
setCapturedImages((prev) => [...prev, currentPreview]);
|
||||
setCurrentPreview(null);
|
||||
}
|
||||
}, [currentPreview]);
|
||||
|
||||
const uploadImages = useCallback(async () => {
|
||||
const imagesToUpload = currentPreview ? [currentPreview, ...capturedImages] : capturedImages;
|
||||
|
||||
if (imagesToUpload.length === 0) return;
|
||||
if (!sessionId) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Convert data URLs to File objects
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < imagesToUpload.length; i++) {
|
||||
const dataUrl = imagesToUpload[i];
|
||||
const response = await fetch(dataUrl);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `scan-${Date.now()}-${i}.jpg`, { type: 'image/jpeg' });
|
||||
files.push(file);
|
||||
setUploadProgress(((i + 1) / (imagesToUpload.length + 1)) * 50); // 0-50% for conversion
|
||||
}
|
||||
|
||||
// Upload to backend
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const uploadResponse = await fetch(`/api/v1/mobile-scanner/upload/${sessionId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
setUploadProgress(100);
|
||||
setUploadSuccess(true);
|
||||
|
||||
// Close the mobile tab after successful upload
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
// Fallback if window.close() doesn't work (some browsers block it)
|
||||
if (!window.closed) {
|
||||
navigate('/');
|
||||
}
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
setUploadError(t('mobileScanner.uploadFailed', 'Upload failed. Please try again.'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [currentPreview, capturedImages, sessionId, navigate, t]);
|
||||
|
||||
const retake = useCallback(() => {
|
||||
setCurrentPreview(null);
|
||||
}, []);
|
||||
|
||||
const clearBatch = useCallback(() => {
|
||||
setCapturedImages([]);
|
||||
}, []);
|
||||
|
||||
const toggleTorch = useCallback(async () => {
|
||||
if (!streamRef.current) return;
|
||||
|
||||
try {
|
||||
const videoTrack = streamRef.current.getVideoTracks()[0];
|
||||
await videoTrack.applyConstraints({
|
||||
advanced: [{ torch: !torchEnabled } as any], // Cast to any for experimental torch API
|
||||
} as any);
|
||||
setTorchEnabled(!torchEnabled);
|
||||
console.log('Torch:', !torchEnabled ? 'ON' : 'OFF');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle torch:', err);
|
||||
}
|
||||
}, [torchEnabled]);
|
||||
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<Box p="xl">
|
||||
<Alert color="red" title={t('mobileScanner.noSession', 'Invalid Session')}>
|
||||
{t('mobileScanner.noSessionMessage', 'Please scan a valid QR code to access this page.')}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (uploadSuccess) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<CheckCircleRoundedIcon style={{ fontSize: '4rem', color: 'var(--mantine-color-green-6)' }} />
|
||||
<Text size="xl" fw="bold" mt="md">
|
||||
{t('mobileScanner.uploadSuccess', 'Upload Successful!')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('mobileScanner.uploadSuccessMessage', 'Your images have been transferred.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--bg-background)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
p="md"
|
||||
style={{
|
||||
background: 'var(--bg-toolbar)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center">
|
||||
<img
|
||||
src={brandIconSrc}
|
||||
alt={t('home.mobile.brandAlt', 'Stirling PDF logo')}
|
||||
style={{ height: '32px', width: '32px' }}
|
||||
/>
|
||||
<img
|
||||
src={brandTextSrc}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '24px' }}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{uploadError && (
|
||||
<Box p="md">
|
||||
<Alert color="red" icon={<ErrorRoundedIcon />} onClose={() => setUploadError(null)} withCloseButton>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<Box p="sm">
|
||||
<Text size="sm" mb="xs">
|
||||
{t('mobileScanner.uploading', 'Uploading...')}
|
||||
</Text>
|
||||
<Progress value={uploadProgress} animated />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{cameraError && (
|
||||
<Box p="md">
|
||||
<Alert color="orange" icon={<InfoRoundedIcon />}>
|
||||
{cameraError}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!currentPreview && (
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="camera" leftSection={<PhotoCameraRoundedIcon />}>
|
||||
{t('mobileScanner.camera', 'Camera')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="file" leftSection={<UploadRoundedIcon />}>
|
||||
{t('mobileScanner.fileUpload', 'File Upload')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="camera" pt="md">
|
||||
<Box style={{ position: 'relative', width: '100%', maxWidth: '100vw', background: '#000', overflow: 'hidden' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '60vh',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
{/* Highlight overlay canvas - shows real-time document edge detection */}
|
||||
<canvas
|
||||
ref={highlightCanvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
opacity: showLiveDetection ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
objectFit: 'contain', // Maintain aspect ratio
|
||||
imageRendering: 'auto', // Smooth scaling
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap="sm" p="sm">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('mobileScanner.liveDetection', 'Live Detection')}
|
||||
</Text>
|
||||
<Switch
|
||||
checked={showLiveDetection}
|
||||
onChange={(e) => setShowLiveDetection(e.currentTarget.checked)}
|
||||
disabled={!openCvReady}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('mobileScanner.autoEnhance', 'Auto-enhance')}
|
||||
</Text>
|
||||
<Switch
|
||||
checked={autoEnhance}
|
||||
onChange={(e) => setAutoEnhance(e.currentTarget.checked)}
|
||||
disabled={!openCvReady}
|
||||
/>
|
||||
</Group>
|
||||
{torchSupported && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('mobileScanner.flashlight', 'Flashlight')}
|
||||
</Text>
|
||||
<Switch checked={torchEnabled} onChange={toggleTorch} />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
<Button
|
||||
fullWidth
|
||||
size="lg"
|
||||
onClick={captureImage}
|
||||
loading={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? t('mobileScanner.processing', 'Processing...')
|
||||
: t('mobileScanner.capture', 'Capture Photo')}
|
||||
</Button>
|
||||
{autoEnhance && openCvReady && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t(
|
||||
'mobileScanner.autoEnhanceInfo',
|
||||
'Document edges will be automatically detected and perspective corrected'
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="file">
|
||||
<Stack gap="sm" p="sm" align="center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
leftSection={<AddPhotoAlternateRoundedIcon />}
|
||||
>
|
||||
{t('mobileScanner.selectImage', 'Select Image')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{currentPreview && (
|
||||
<Stack gap="sm" p="sm">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('mobileScanner.preview', 'Preview')}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#000',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<img src={currentPreview} alt="Preview" style={{ width: '100%', display: 'block' }} />
|
||||
</Box>
|
||||
<Group grow>
|
||||
<Button variant="outline" onClick={retake}>
|
||||
{t('mobileScanner.retake', 'Retake')}
|
||||
</Button>
|
||||
<Button variant="light" onClick={addToBatch} color="green">
|
||||
{t('mobileScanner.addToBatch', 'Add to Batch')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button fullWidth onClick={uploadImages} loading={isUploading}>
|
||||
{t('mobileScanner.upload', 'Upload')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{capturedImages.length > 0 && (
|
||||
<Box p="sm" style={{ borderTop: '1px solid var(--border-subtle)' }}>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('mobileScanner.batchImages', 'Batch')} ({capturedImages.length})
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="xs" variant="outline" onClick={clearBatch} color="red">
|
||||
{t('mobileScanner.clearBatch', 'Clear')}
|
||||
</Button>
|
||||
<Button size="xs" onClick={uploadImages} loading={isUploading}>
|
||||
{t('mobileScanner.uploadAll', 'Upload All')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ display: 'flex', gap: 'var(--space-sm)', overflowX: 'auto', paddingBottom: 'var(--space-sm)' }}>
|
||||
{capturedImages.map((img, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
style={{
|
||||
minWidth: '80px',
|
||||
height: '80px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
overflow: 'hidden',
|
||||
border: '2px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
<img src={img} alt={`Capture ${idx + 1}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useAddPageNumbersOperation } from "@app/components/tools/addPageNumbers
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import AddPageNumbersPositionSettings from "@app/components/tools/addPageNumbers/AddPageNumbersPositionSettings";
|
||||
import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
|
||||
import { useAddPageNumbersTips } from "@app/components/tooltips/useAddPageNumbersTips";
|
||||
|
||||
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,6 +17,7 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
|
||||
const params = useAddPageNumbersParameters();
|
||||
const operation = useAddPageNumbersOperation();
|
||||
const pageNumbersTips = useAddPageNumbersTips();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-page-numbers");
|
||||
|
||||
@@ -66,6 +68,7 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.POSITION_AND_PAGES),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
|
||||
isVisible: hasFiles || hasResults,
|
||||
tooltip: pageNumbersTips,
|
||||
content: (
|
||||
<AddPageNumbersPositionSettings
|
||||
parameters={params.parameters}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import ObscuredOverlay from "@app/components/shared/ObscuredOverlay";
|
||||
import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings";
|
||||
import StampPositionFormattingSettings from "@app/components/tools/addStamp/StampPositionFormattingSettings";
|
||||
import { useAddStampSetupTips, useAddStampPositionTips } from "@app/components/tooltips/useAddStampTips";
|
||||
|
||||
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,6 +25,8 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
|
||||
const params = useAddStampParameters();
|
||||
const operation = useAddStampOperation();
|
||||
const stampSetupTips = useAddStampSetupTips();
|
||||
const stampPositionTips = useAddStampPositionTips();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-stamp");
|
||||
|
||||
@@ -75,6 +78,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
|
||||
isVisible: hasFiles || hasResults,
|
||||
tooltip: stampSetupTips,
|
||||
content: (
|
||||
<StampSetupSettings
|
||||
parameters={params.parameters}
|
||||
@@ -90,6 +94,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.POSITION_FORMATTING),
|
||||
isVisible: hasFiles || hasResults,
|
||||
tooltip: stampPositionTips,
|
||||
content: (
|
||||
<Stack gap="md" justify="space-between">
|
||||
{/* Mode toggle: Quick grid vs Custom drag - only show for image stamps */}
|
||||
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,383 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AnnotationAPI, AnnotationToolId } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
interface UseAnnotationSelectionParams {
|
||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
||||
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
|
||||
activeToolRef: React.MutableRefObject<AnnotationToolId>;
|
||||
manualToolSwitch: React.MutableRefObject<boolean>;
|
||||
setActiveTool: (toolId: AnnotationToolId) => void;
|
||||
setSelectedTextDraft: (text: string) => void;
|
||||
setSelectedFontSize: (size: number) => void;
|
||||
setInkWidth: (value: number) => void;
|
||||
setFreehandHighlighterWidth?: (value: number) => void;
|
||||
setShapeThickness: (value: number) => void;
|
||||
setTextColor: (value: string) => void;
|
||||
setTextBackgroundColor: (value: string) => void;
|
||||
setNoteBackgroundColor: (value: string) => void;
|
||||
setInkColor: (value: string) => void;
|
||||
setHighlightColor: (value: string) => void;
|
||||
setHighlightOpacity: (value: number) => void;
|
||||
setUnderlineColor: (value: string) => void;
|
||||
setUnderlineOpacity: (value: number) => void;
|
||||
setStrikeoutColor: (value: string) => void;
|
||||
setStrikeoutOpacity: (value: number) => void;
|
||||
setSquigglyColor: (value: string) => void;
|
||||
setSquigglyOpacity: (value: number) => void;
|
||||
setShapeStrokeColor: (value: string) => void;
|
||||
setShapeFillColor: (value: string) => void;
|
||||
setShapeOpacity: (value: number) => void;
|
||||
setShapeStrokeOpacity: (value: number) => void;
|
||||
setShapeFillOpacity: (value: number) => void;
|
||||
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
|
||||
}
|
||||
|
||||
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
|
||||
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] as const;
|
||||
|
||||
const isTextMarkupAnnotation = (annotation: any): boolean => {
|
||||
const toolId =
|
||||
annotation?.customData?.annotationToolId ||
|
||||
annotation?.customData?.toolId ||
|
||||
annotation?.object?.customData?.annotationToolId ||
|
||||
annotation?.object?.customData?.toolId;
|
||||
if (toolId && MARKUP_TOOL_IDS.includes(toolId)) return true;
|
||||
|
||||
const type = annotation?.type ?? annotation?.object?.type;
|
||||
if (typeof type === 'number' && [9, 10, 11, 12].includes(type)) return true;
|
||||
|
||||
const subtype = annotation?.subtype ?? annotation?.object?.subtype;
|
||||
if (typeof subtype === 'string') {
|
||||
const lower = subtype.toLowerCase();
|
||||
if (MARKUP_TOOL_IDS.some((t) => lower.includes(t))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): boolean => {
|
||||
const toolId =
|
||||
derivedTool ||
|
||||
annotation?.customData?.annotationToolId ||
|
||||
annotation?.customData?.toolId ||
|
||||
annotation?.object?.customData?.annotationToolId ||
|
||||
annotation?.object?.customData?.toolId;
|
||||
|
||||
if (toolId && (MARKUP_TOOL_IDS.includes(toolId as any) || DRAWING_TOOL_IDS.includes(toolId as any))) {
|
||||
return true;
|
||||
}
|
||||
const type = annotation?.type ?? annotation?.object?.type;
|
||||
if (typeof type === 'number' && type === 15) return true; // ink family
|
||||
if (isTextMarkupAnnotation(annotation)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export function useAnnotationSelection({
|
||||
annotationApiRef,
|
||||
deriveToolFromAnnotation,
|
||||
activeToolRef,
|
||||
manualToolSwitch,
|
||||
setActiveTool,
|
||||
setSelectedTextDraft,
|
||||
setSelectedFontSize,
|
||||
setInkWidth,
|
||||
setShapeThickness,
|
||||
setTextColor,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
setFreehandHighlighterWidth,
|
||||
}: UseAnnotationSelectionParams) {
|
||||
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
|
||||
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
|
||||
const selectedAnnIdRef = useRef<string | null>(null);
|
||||
|
||||
const applySelectionFromAnnotation = useCallback(
|
||||
(ann: any | null) => {
|
||||
const annObject = ann?.object ?? ann ?? null;
|
||||
const annId = annObject?.id ?? null;
|
||||
const type = annObject?.type;
|
||||
const derivedTool = annObject ? deriveToolFromAnnotation(annObject) : undefined;
|
||||
selectedAnnIdRef.current = annId;
|
||||
setSelectedAnnId(annId);
|
||||
// Normalize selected annotation to always expose .object for edit panels
|
||||
const normalizedSelection = ann?.object ? ann : annObject ? { object: annObject } : null;
|
||||
setSelectedAnn(normalizedSelection);
|
||||
|
||||
if (annObject?.contents !== undefined) {
|
||||
setSelectedTextDraft(annObject.contents ?? '');
|
||||
}
|
||||
if (annObject?.fontSize !== undefined) {
|
||||
setSelectedFontSize(annObject.fontSize ?? 14);
|
||||
}
|
||||
if (annObject?.textAlign !== undefined) {
|
||||
const align = annObject.textAlign;
|
||||
if (typeof align === 'string') {
|
||||
const normalized = align === 'center' ? 'center' : align === 'right' ? 'right' : 'left';
|
||||
setTextAlignment(normalized);
|
||||
} else if (typeof align === 'number') {
|
||||
const normalized = align === 1 ? 'center' : align === 2 ? 'right' : 'left';
|
||||
setTextAlignment(normalized);
|
||||
}
|
||||
}
|
||||
if (type === 3) {
|
||||
const background =
|
||||
(annObject?.backgroundColor as string | undefined) ||
|
||||
(annObject?.fillColor as string | undefined) ||
|
||||
undefined;
|
||||
const textColor = (annObject?.textColor as string | undefined) || (annObject?.color as string | undefined);
|
||||
if (textColor) {
|
||||
setTextColor(textColor);
|
||||
}
|
||||
if (derivedTool === 'note') {
|
||||
setNoteBackgroundColor(background || '');
|
||||
} else {
|
||||
setTextBackgroundColor(background || '');
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 15) {
|
||||
const width =
|
||||
annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth ?? annObject?.thickness;
|
||||
if (derivedTool === 'inkHighlighter') {
|
||||
if (annObject?.color) setHighlightColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) {
|
||||
setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
}
|
||||
if (width !== undefined && setFreehandHighlighterWidth) {
|
||||
setFreehandHighlighterWidth(width);
|
||||
}
|
||||
} else {
|
||||
if (width !== undefined) setInkWidth(width ?? 2);
|
||||
if (annObject?.color) {
|
||||
setInkColor(annObject.color);
|
||||
}
|
||||
}
|
||||
} else if (type >= 4 && type <= 8) {
|
||||
const width = annObject?.strokeWidth ?? annObject?.borderWidth ?? annObject?.lineWidth;
|
||||
if (width !== undefined) {
|
||||
setShapeThickness(width ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 9) {
|
||||
if (annObject?.color) setHighlightColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 10) {
|
||||
if (annObject?.color) setUnderlineColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setUnderlineOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 12) {
|
||||
if (annObject?.color) setStrikeoutColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setStrikeoutOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 11) {
|
||||
if (annObject?.color) setSquigglyColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) setSquigglyOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
}
|
||||
|
||||
if ([4, 5, 6, 7, 8].includes(type)) {
|
||||
const stroke = (annObject?.strokeColor as string | undefined) ?? (annObject?.color as string | undefined);
|
||||
if (stroke) setShapeStrokeColor(stroke);
|
||||
if ([5, 6, 7].includes(type)) {
|
||||
const fill = (annObject?.color as string | undefined) ?? (annObject?.fillColor as string | undefined);
|
||||
if (fill) setShapeFillColor(fill);
|
||||
}
|
||||
const opacity =
|
||||
annObject?.opacity !== undefined ? Math.round((annObject.opacity ?? 1) * 100) : undefined;
|
||||
const strokeOpacityValue =
|
||||
annObject?.strokeOpacity !== undefined
|
||||
? Math.round((annObject.strokeOpacity ?? 1) * 100)
|
||||
: undefined;
|
||||
const fillOpacityValue =
|
||||
annObject?.fillOpacity !== undefined ? Math.round((annObject.fillOpacity ?? 1) * 100) : undefined;
|
||||
if (opacity !== undefined) {
|
||||
setShapeOpacity(opacity);
|
||||
setShapeStrokeOpacity(strokeOpacityValue ?? opacity);
|
||||
setShapeFillOpacity(fillOpacityValue ?? opacity);
|
||||
} else {
|
||||
if (strokeOpacityValue !== undefined) setShapeStrokeOpacity(strokeOpacityValue);
|
||||
if (fillOpacityValue !== undefined) setShapeFillOpacity(fillOpacityValue);
|
||||
}
|
||||
}
|
||||
|
||||
const matchingTool = derivedTool;
|
||||
const stayOnPlacement = shouldStayOnPlacementTool(annObject, matchingTool);
|
||||
if (matchingTool && activeToolRef.current !== 'select' && !stayOnPlacement) {
|
||||
activeToolRef.current = 'select';
|
||||
setActiveTool('select');
|
||||
// Immediately enable select tool to avoid re-entering placement after creation.
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
} else if (activeToolRef.current === 'select') {
|
||||
// Keep the viewer in Select mode so clicking existing annotations does not re-enable placement.
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
}
|
||||
},
|
||||
[
|
||||
activeToolRef,
|
||||
deriveToolFromAnnotation,
|
||||
manualToolSwitch,
|
||||
setActiveTool,
|
||||
setInkWidth,
|
||||
setNoteBackgroundColor,
|
||||
setSelectedFontSize,
|
||||
setSelectedTextDraft,
|
||||
setShapeThickness,
|
||||
setTextBackgroundColor,
|
||||
setTextColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
setFreehandHighlighterWidth,
|
||||
shouldStayOnPlacementTool,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const api = annotationApiRef.current as any;
|
||||
if (!api) return;
|
||||
|
||||
const checkSelection = () => {
|
||||
let ann: any = null;
|
||||
if (typeof api.getSelectedAnnotation === 'function') {
|
||||
try {
|
||||
ann = api.getSelectedAnnotation();
|
||||
} catch (error) {
|
||||
// Some builds of the annotation plugin can throw when reading
|
||||
// internal selection state (e.g., accessing `selectedUid` on
|
||||
// an undefined object). Treat this as "no current selection"
|
||||
// instead of crashing the annotations tool.
|
||||
console.error('[useAnnotationSelection] getSelectedAnnotation failed:', error);
|
||||
ann = null;
|
||||
}
|
||||
}
|
||||
const currentId = ann?.object?.id ?? ann?.id ?? null;
|
||||
if (currentId !== selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(ann ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (typeof api.onAnnotationEvent === 'function') {
|
||||
const handler = (event: any) => {
|
||||
const ann = event?.annotation ?? event?.selectedAnnotation ?? null;
|
||||
const eventType = event?.type;
|
||||
switch (eventType) {
|
||||
case 'create':
|
||||
case 'add':
|
||||
case 'added':
|
||||
case 'created':
|
||||
case 'annotationCreated':
|
||||
case 'annotationAdded':
|
||||
case 'complete': {
|
||||
const eventAnn = ann ?? api.getSelectedAnnotation?.();
|
||||
applySelectionFromAnnotation(eventAnn);
|
||||
const currentTool = activeToolRef.current;
|
||||
const tool =
|
||||
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
|
||||
currentTool;
|
||||
const stayOnPlacement =
|
||||
shouldStayOnPlacementTool(eventAnn, tool) ||
|
||||
(tool ? DRAWING_TOOL_IDS.includes(tool as any) : false);
|
||||
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
|
||||
activeToolRef.current = 'select';
|
||||
setActiveTool('select');
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
}
|
||||
// Re-read selection after the viewer updates to ensure we have the full annotation object for the edit panel.
|
||||
setTimeout(() => {
|
||||
const selected = api.getSelectedAnnotation?.();
|
||||
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
|
||||
const derivedAfter =
|
||||
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
|
||||
const stayOnPlacementAfter =
|
||||
shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter) ||
|
||||
(derivedAfter ? DRAWING_TOOL_IDS.includes(derivedAfter as any) : false);
|
||||
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
|
||||
activeToolRef.current = 'select';
|
||||
setActiveTool('select');
|
||||
annotationApiRef.current?.activateAnnotationTool?.('select');
|
||||
}
|
||||
}, 50);
|
||||
break;
|
||||
}
|
||||
case 'select':
|
||||
case 'selected':
|
||||
case 'annotationSelected':
|
||||
case 'annotationClicked':
|
||||
case 'annotationTapped':
|
||||
applySelectionFromAnnotation(ann ?? api.getSelectedAnnotation?.());
|
||||
break;
|
||||
case 'deselect':
|
||||
case 'clearSelection':
|
||||
applySelectionFromAnnotation(null);
|
||||
break;
|
||||
case 'delete':
|
||||
case 'remove':
|
||||
if (ann?.id && ann.id === selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(null);
|
||||
}
|
||||
break;
|
||||
case 'update':
|
||||
case 'change':
|
||||
if (selectedAnnIdRef.current) {
|
||||
const current = api.getSelectedAnnotation?.();
|
||||
if (current) {
|
||||
applySelectionFromAnnotation(current);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = api.onAnnotationEvent(handler);
|
||||
interval = setInterval(checkSelection, 450);
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}
|
||||
|
||||
interval = setInterval(checkSelection, 350);
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [annotationApiRef, applySelectionFromAnnotation]);
|
||||
|
||||
return {
|
||||
selectedAnn,
|
||||
selectedAnnId,
|
||||
selectedAnnIdRef,
|
||||
setSelectedAnn,
|
||||
setSelectedAnnId,
|
||||
applySelectionFromAnnotation,
|
||||
};
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { AnnotationToolId } from '@app/components/viewer/viewerTypes';
|
||||
|
||||
type Size = { width: number; height: number };
|
||||
|
||||
export type BuildToolOptionsExtras = {
|
||||
includeMetadata?: boolean;
|
||||
stampImageData?: string;
|
||||
stampImageSize?: Size | null;
|
||||
};
|
||||
|
||||
interface StyleState {
|
||||
inkColor: string;
|
||||
inkWidth: number;
|
||||
highlightColor: string;
|
||||
highlightOpacity: number;
|
||||
freehandHighlighterWidth: number;
|
||||
underlineColor: string;
|
||||
underlineOpacity: number;
|
||||
strikeoutColor: string;
|
||||
strikeoutOpacity: number;
|
||||
squigglyColor: string;
|
||||
squigglyOpacity: number;
|
||||
textColor: string;
|
||||
textSize: number;
|
||||
textAlignment: 'left' | 'center' | 'right';
|
||||
textBackgroundColor: string;
|
||||
noteBackgroundColor: string;
|
||||
shapeStrokeColor: string;
|
||||
shapeFillColor: string;
|
||||
shapeOpacity: number;
|
||||
shapeStrokeOpacity: number;
|
||||
shapeFillOpacity: number;
|
||||
shapeThickness: number;
|
||||
}
|
||||
|
||||
interface StyleActions {
|
||||
setInkColor: (value: string) => void;
|
||||
setInkWidth: (value: number) => void;
|
||||
setHighlightColor: (value: string) => void;
|
||||
setHighlightOpacity: (value: number) => void;
|
||||
setFreehandHighlighterWidth: (value: number) => void;
|
||||
setUnderlineColor: (value: string) => void;
|
||||
setUnderlineOpacity: (value: number) => void;
|
||||
setStrikeoutColor: (value: string) => void;
|
||||
setStrikeoutOpacity: (value: number) => void;
|
||||
setSquigglyColor: (value: string) => void;
|
||||
setSquigglyOpacity: (value: number) => void;
|
||||
setTextColor: (value: string) => void;
|
||||
setTextSize: (value: number) => void;
|
||||
setTextAlignment: (value: 'left' | 'center' | 'right') => void;
|
||||
setTextBackgroundColor: (value: string) => void;
|
||||
setNoteBackgroundColor: (value: string) => void;
|
||||
setShapeStrokeColor: (value: string) => void;
|
||||
setShapeFillColor: (value: string) => void;
|
||||
setShapeOpacity: (value: number) => void;
|
||||
setShapeStrokeOpacity: (value: number) => void;
|
||||
setShapeFillOpacity: (value: number) => void;
|
||||
setShapeThickness: (value: number) => void;
|
||||
}
|
||||
|
||||
export type BuildToolOptionsFn = (
|
||||
toolId: AnnotationToolId,
|
||||
extras?: BuildToolOptionsExtras
|
||||
) => Record<string, unknown>;
|
||||
|
||||
export interface AnnotationStyleStateReturn {
|
||||
styleState: StyleState;
|
||||
styleActions: StyleActions;
|
||||
buildToolOptions: BuildToolOptionsFn;
|
||||
getActiveColor: (target: string | null) => string;
|
||||
}
|
||||
|
||||
export const useAnnotationStyleState = (
|
||||
cssToPdfSize?: (size: Size) => Size
|
||||
): AnnotationStyleStateReturn => {
|
||||
const [inkColor, setInkColor] = useState('#1f2933');
|
||||
const [inkWidth, setInkWidth] = useState(2);
|
||||
const [highlightColor, setHighlightColor] = useState('#ffd54f');
|
||||
const [highlightOpacity, setHighlightOpacity] = useState(60);
|
||||
const [freehandHighlighterWidth, setFreehandHighlighterWidth] = useState(6);
|
||||
const [underlineColor, setUnderlineColor] = useState('#ffb300');
|
||||
const [underlineOpacity, setUnderlineOpacity] = useState(100);
|
||||
const [strikeoutColor, setStrikeoutColor] = useState('#e53935');
|
||||
const [strikeoutOpacity, setStrikeoutOpacity] = useState(100);
|
||||
const [squigglyColor, setSquigglyColor] = useState('#00acc1');
|
||||
const [squigglyOpacity, setSquigglyOpacity] = useState(100);
|
||||
const [textColor, setTextColor] = useState('#111111');
|
||||
const [textSize, setTextSize] = useState(14);
|
||||
const [textAlignment, setTextAlignment] = useState<'left' | 'center' | 'right'>('left');
|
||||
const [textBackgroundColor, setTextBackgroundColor] = useState<string>('');
|
||||
const [noteBackgroundColor, setNoteBackgroundColor] = useState('#ffd54f');
|
||||
const [shapeStrokeColor, setShapeStrokeColor] = useState('#cf5b5b');
|
||||
const [shapeFillColor, setShapeFillColor] = useState('#0000ff');
|
||||
const [shapeOpacity, setShapeOpacity] = useState(50);
|
||||
const [shapeStrokeOpacity, setShapeStrokeOpacity] = useState(50);
|
||||
const [shapeFillOpacity, setShapeFillOpacity] = useState(50);
|
||||
const [shapeThickness, setShapeThickness] = useState(2);
|
||||
|
||||
const buildToolOptions = useCallback<BuildToolOptionsFn>(
|
||||
(toolId, extras) => {
|
||||
const includeMetadata = extras?.includeMetadata ?? true;
|
||||
const metadata = includeMetadata
|
||||
? {
|
||||
customData: {
|
||||
toolId,
|
||||
annotationToolId: toolId,
|
||||
source: 'annotate',
|
||||
author: 'User',
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
switch (toolId) {
|
||||
case 'ink':
|
||||
return { color: inkColor, thickness: inkWidth, ...metadata };
|
||||
case 'inkHighlighter':
|
||||
return {
|
||||
color: highlightColor,
|
||||
opacity: highlightOpacity / 100,
|
||||
thickness: freehandHighlighterWidth,
|
||||
...metadata,
|
||||
};
|
||||
case 'highlight':
|
||||
return { color: highlightColor, opacity: highlightOpacity / 100, ...metadata };
|
||||
case 'underline':
|
||||
return { color: underlineColor, opacity: underlineOpacity / 100, ...metadata };
|
||||
case 'strikeout':
|
||||
return { color: strikeoutColor, opacity: strikeoutOpacity / 100, ...metadata };
|
||||
case 'squiggly':
|
||||
return { color: squigglyColor, opacity: squigglyOpacity / 100, ...metadata };
|
||||
case 'text': {
|
||||
const textAlignNumber = textAlignment === 'left' ? 0 : textAlignment === 'center' ? 1 : 2;
|
||||
return {
|
||||
color: textColor,
|
||||
textColor: textColor,
|
||||
fontSize: textSize,
|
||||
textAlign: textAlignNumber,
|
||||
...(textBackgroundColor ? { fillColor: textBackgroundColor } : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
case 'note': {
|
||||
const noteFillColor = noteBackgroundColor || 'transparent';
|
||||
return {
|
||||
color: textColor,
|
||||
fillColor: noteFillColor,
|
||||
opacity: 1,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
case 'square':
|
||||
case 'circle':
|
||||
case 'polygon':
|
||||
return {
|
||||
color: shapeFillColor,
|
||||
strokeColor: shapeStrokeColor,
|
||||
opacity: shapeOpacity / 100,
|
||||
strokeOpacity: shapeStrokeOpacity / 100,
|
||||
fillOpacity: shapeFillOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
...metadata,
|
||||
};
|
||||
case 'line':
|
||||
case 'polyline':
|
||||
case 'lineArrow':
|
||||
return {
|
||||
color: shapeStrokeColor,
|
||||
strokeColor: shapeStrokeColor,
|
||||
opacity: shapeStrokeOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
...metadata,
|
||||
};
|
||||
case 'stamp': {
|
||||
const pdfSize =
|
||||
extras?.stampImageSize && cssToPdfSize ? cssToPdfSize(extras.stampImageSize) : undefined;
|
||||
return {
|
||||
imageSrc: extras?.stampImageData,
|
||||
...(pdfSize ? { imageSize: pdfSize } : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...metadata };
|
||||
}
|
||||
},
|
||||
[
|
||||
cssToPdfSize,
|
||||
freehandHighlighterWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
inkColor,
|
||||
inkWidth,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeFillOpacity,
|
||||
shapeOpacity,
|
||||
shapeStrokeColor,
|
||||
shapeStrokeOpacity,
|
||||
shapeThickness,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
textSize,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
]
|
||||
);
|
||||
|
||||
const getActiveColor = useCallback(
|
||||
(target: string | null) => {
|
||||
if (target === 'ink') return inkColor;
|
||||
if (target === 'highlight' || target === 'inkHighlighter') return highlightColor;
|
||||
if (target === 'underline') return underlineColor;
|
||||
if (target === 'strikeout') return strikeoutColor;
|
||||
if (target === 'squiggly') return squigglyColor;
|
||||
if (target === 'shapeStroke') return shapeStrokeColor;
|
||||
if (target === 'shapeFill') return shapeFillColor;
|
||||
if (target === 'textBackground') return textBackgroundColor || '#ffffff';
|
||||
if (target === 'noteBackground') return noteBackgroundColor || '#ffffff';
|
||||
return textColor;
|
||||
},
|
||||
[
|
||||
highlightColor,
|
||||
inkColor,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeStrokeColor,
|
||||
squigglyColor,
|
||||
strikeoutColor,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
underlineColor,
|
||||
]
|
||||
);
|
||||
|
||||
const styleState: StyleState = useMemo(
|
||||
() => ({
|
||||
inkColor,
|
||||
inkWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
freehandHighlighterWidth,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
textColor,
|
||||
textSize,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
noteBackgroundColor,
|
||||
shapeStrokeColor,
|
||||
shapeFillColor,
|
||||
shapeOpacity,
|
||||
shapeStrokeOpacity,
|
||||
shapeFillOpacity,
|
||||
shapeThickness,
|
||||
}),
|
||||
[
|
||||
freehandHighlighterWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
inkColor,
|
||||
inkWidth,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeFillOpacity,
|
||||
shapeOpacity,
|
||||
shapeStrokeColor,
|
||||
shapeStrokeOpacity,
|
||||
shapeThickness,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
textSize,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
]
|
||||
);
|
||||
|
||||
const styleActions: StyleActions = {
|
||||
setInkColor,
|
||||
setInkWidth,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setFreehandHighlighterWidth,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setTextColor,
|
||||
setTextSize,
|
||||
setTextAlignment,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setShapeThickness,
|
||||
};
|
||||
|
||||
return {
|
||||
styleState,
|
||||
styleActions,
|
||||
buildToolOptions,
|
||||
getActiveColor,
|
||||
};
|
||||
};
|
||||
@@ -25,7 +25,6 @@ export const CORE_REGULAR_TOOL_IDS = [
|
||||
'ocr',
|
||||
'addImage',
|
||||
'rotate',
|
||||
'annotate',
|
||||
'scannerImageSplit',
|
||||
'editTableOfContents',
|
||||
'scannerEffect',
|
||||
|
||||
@@ -70,8 +70,6 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
'/scanner-image-split': 'scannerImageSplit',
|
||||
|
||||
// Annotation and content removal
|
||||
'/annotations': 'annotate',
|
||||
'/annotate': 'annotate',
|
||||
'/remove-annotations': 'removeAnnotations',
|
||||
'/remove-image': 'removeImage',
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ import { Routes, Route } from "react-router-dom";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
|
||||
import Landing from "@app/routes/Landing";
|
||||
import Login from "@app/routes/Login";
|
||||
import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import InviteAccept from "@app/routes/InviteAccept";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
@@ -19,24 +22,53 @@ import "@app/styles/auth-theme.css";
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
// Minimal providers for mobile scanner - no API calls, no authentication
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
{children}
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
{/* Auth routes - no nested providers needed */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
<Routes>
|
||||
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
|
||||
<Route
|
||||
path="/mobile-scanner"
|
||||
element={
|
||||
<MobileScannerProviders>
|
||||
<MobileScannerPage />
|
||||
</MobileScannerProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<AppProviders>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
{/* Auth routes - no nested providers needed */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
</Routes>
|
||||
<Onboarding />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ export default defineConfig(({ mode }) => {
|
||||
//provides static pdfium so embedpdf can run without cdn
|
||||
src: 'node_modules/@embedpdf/pdfium/dist/pdfium.wasm',
|
||||
dest: 'pdfium'
|
||||
},
|
||||
{
|
||||
// Copy jscanify vendor files to dist
|
||||
src: 'public/vendor/jscanify/*',
|
||||
dest: 'vendor/jscanify'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user