Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ce367990d | ||
|
|
43beadbdcb | ||
|
|
416d79aed3 | ||
|
|
abc0988fdf | ||
|
|
c7e0ea5b5b | ||
|
|
b35447934e | ||
|
|
d1e82eb8f1 | ||
|
|
7d44cc1a40 | ||
|
|
0bdc6466ca | ||
|
|
f2a6e95fcf | ||
|
|
0c08764669 | ||
|
|
9758e871d4 | ||
|
|
18fa16f08e | ||
|
|
233b710b78 | ||
|
|
d613a4659e | ||
|
|
03f484e0c0 | ||
|
|
fd52dc0226 | ||
|
|
21b1428ab5 | ||
|
|
1219cebd07 |
@@ -203,3 +203,10 @@ id_ed25519.pub
|
||||
|
||||
# node_modules
|
||||
node_modules/
|
||||
|
||||
# Translation temp files
|
||||
*_compact.json
|
||||
*compact*.json
|
||||
test_batch.json
|
||||
*.backup.*.json
|
||||
frontend/public/locales/*/translation.backup*.json
|
||||
|
||||
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
@@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -111,6 +114,32 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse client file IDs from JSON string
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
// Simple JSON array parsing - remove brackets and split by comma
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
if (inside.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
// Create the document outline
|
||||
@@ -177,15 +206,48 @@ public class MergeController {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
long totalSize = 0;
|
||||
for (MultipartFile multipartFile : files) {
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
MultipartFile multipartFile = files[index];
|
||||
totalSize += multipartFile.getSize();
|
||||
File tempFile =
|
||||
GeneralUtils.convertMultipartFileToFile(
|
||||
multipartFile); // Convert MultipartFile to File
|
||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
||||
|
||||
// Pre-validate each PDF so we can report which one(s) are broken
|
||||
// Use the original MultipartFile to avoid deleting the tempFile during validation
|
||||
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
|
||||
// OK
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF pre-validate", e);
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
}
|
||||
|
||||
if (!invalidIndexes.isEmpty()) {
|
||||
// Parse client file IDs (always present from frontend)
|
||||
String[] clientIds = parseClientFileIds(request.getClientFileIds());
|
||||
|
||||
// Map invalid indexes to client IDs
|
||||
List<String> errorFileIds = new ArrayList<>();
|
||||
for (Integer index : invalidIndexes) {
|
||||
if (index < clientIds.length) {
|
||||
errorFileIds.add(clientIds[index]);
|
||||
}
|
||||
}
|
||||
|
||||
String payload =
|
||||
String.format(
|
||||
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
|
||||
errorFileIds.toString());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(payload.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
|
||||
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
|
||||
|
||||
|
||||
@@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean generateToc = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
private String clientFileIds;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
@@ -6,6 +6,11 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Add .mjs MIME type mapping
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -90,6 +95,14 @@ http {
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Serve .mjs files with correct MIME type (must come before general static assets)
|
||||
location ~* \.mjs$ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type "text/javascript; charset=utf-8" always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
Generated
+192
-111
@@ -10,21 +10,24 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.2.1",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.2.1",
|
||||
"@embedpdf/plugin-loader": "^1.2.1",
|
||||
"@embedpdf/plugin-pan": "^1.2.1",
|
||||
"@embedpdf/plugin-render": "^1.2.1",
|
||||
"@embedpdf/plugin-rotate": "^1.2.1",
|
||||
"@embedpdf/plugin-scroll": "^1.2.1",
|
||||
"@embedpdf/plugin-search": "^1.2.1",
|
||||
"@embedpdf/plugin-selection": "^1.2.1",
|
||||
"@embedpdf/plugin-spread": "^1.2.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.2.1",
|
||||
"@embedpdf/plugin-tiling": "^1.2.1",
|
||||
"@embedpdf/plugin-viewport": "^1.2.1",
|
||||
"@embedpdf/plugin-zoom": "^1.2.1",
|
||||
"@embedpdf/core": "^1.3.1",
|
||||
"@embedpdf/engines": "^1.3.1",
|
||||
"@embedpdf/plugin-annotation": "^1.3.1",
|
||||
"@embedpdf/plugin-export": "^1.3.1",
|
||||
"@embedpdf/plugin-history": "^1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.1",
|
||||
"@embedpdf/plugin-loader": "^1.3.1",
|
||||
"@embedpdf/plugin-pan": "^1.3.1",
|
||||
"@embedpdf/plugin-render": "^1.3.1",
|
||||
"@embedpdf/plugin-rotate": "^1.3.1",
|
||||
"@embedpdf/plugin-scroll": "^1.3.1",
|
||||
"@embedpdf/plugin-search": "^1.3.1",
|
||||
"@embedpdf/plugin-selection": "^1.3.1",
|
||||
"@embedpdf/plugin-spread": "^1.3.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.1",
|
||||
"@embedpdf/plugin-tiling": "^1.3.1",
|
||||
"@embedpdf/plugin-viewport": "^1.3.1",
|
||||
"@embedpdf/plugin-zoom": "^1.3.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -488,12 +491,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-2VwRPsN3+LmaBrD8TCN1t1ni/Vc9CxAfl/SApDjZYwE7zOieQT4ZHt+nkgF0F4I3xSgvvyHDjmOonhjBIrT6xA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||
"integrity": "sha512-2Az6trhiMMBIv+GFvV8H8UOS1gwQn7NK0KaJMcdsZbUHYLO0P95aVd6Pi/GRzEH4XyF51TDIoTOAUtf07TQ5dQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.2.1",
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/engines": "1.3.1",
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -503,13 +507,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.2.1.tgz",
|
||||
"integrity": "sha512-nhycZ7Buq2B34dcpo6n7RdFwdhwTvKzvnRy7QX+uU00Dz5vftkCG4OK+pBVzxE4y7vAu+Yb4wNpdc7HmIj3B6w==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.1.tgz",
|
||||
"integrity": "sha512-G3pI+18la7spviUMuA5s9/hV95jlfkA2+CNxqlHBO5ocw3641E3d36Lv+mx+6yU7k0B5vEOQPZDGRMg7KFziBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1",
|
||||
"@embedpdf/pdfium": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1",
|
||||
"@embedpdf/pdfium": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -519,26 +523,79 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.2.1.tgz",
|
||||
"integrity": "sha512-FzJU51jsqihfgt50B00FEpgyym87/Dn2iGmMq4++Vu/oO6qBx/y69m4/cCAh4p4KkTJsvKNWC7T7dwSKa0FjHA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.1.tgz",
|
||||
"integrity": "sha512-OzmO1rQAuOP/Y3aYXmW21dPNAx49olhr9ZO2hDdI0fbNBHTVGxnaKqOISxVmUz7TmhTwVBljERACnaA8Ib4b4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.2.1.tgz",
|
||||
"integrity": "sha512-QWf1jg7EqUlku2q6KYhlXCNfk5IAykFerPuzKJepHTeAEaRcAfu84fJgEsoUTCK4D6dfzVNp2Iuxw6Kv7MpSeg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.1.tgz",
|
||||
"integrity": "sha512-qYGSS5ntz6DSY9Cxw/aigvHqGB+AKJLEcymNTZOL0GdlBzZpL++dOIYNEYHO2Tm/lOQVpE7I0e+Xh2TvD8O1zQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.2.1.tgz",
|
||||
"integrity": "sha512-HhEBuDjDNMH6wu76Eo3yHwjG01U1lNZShkOsFoib/rtx8HByTgZS8iVpovaOprr6gfS04ZLqWcsN1nt5qAH90w==",
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.1.tgz",
|
||||
"integrity": "sha512-mmePRYYBB8v8NIZ95XVfFkpyQ2QiKIGdWyvrPeJXSbL3/K6d6ix+o/jHBVvBWyTsQzdIlzs+FW8+iT0M1zkEow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1",
|
||||
"@embedpdf/utils": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-history": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-selection": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.1.tgz",
|
||||
"integrity": "sha512-reb03vNPFP5GuIAFExMcuYBVYu/deVO2v8EoCwRZ/lzzYMORIkJjpNWDQPo9VfyGBh1x4/o3CHvxisU1Y1tDLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.1.tgz",
|
||||
"integrity": "sha512-HrPkWQmAk08mbHiOcIN4htVq5KJMqI9zSjAqaYQEhV/TugeHfWVpK+xMst/PzuFb14HWgk5gWXjtV5E4SDlw9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.1.tgz",
|
||||
"integrity": "sha512-8h3y5a9tQ1fZlc4mP1/+XKyuHWwcQEm9AujKxy+6f6omtCBzpnKrH95bURgYOzQEBGY7d5C3HvG6JOlh0o1x3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -546,14 +603,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-loader": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.2.1.tgz",
|
||||
"integrity": "sha512-VblKErfEiHcVao18TfCmc0UJlKAkqxE29DaLJrXQHGUw/qc+pC9HlvMVpDz3+Eb13UafYS6ZUZuEng2/fQ+JJw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.1.tgz",
|
||||
"integrity": "sha512-NjNmA7TOs3E/zwb9I+YohzyGkxq8y5NUGu0MKgh2g41lZoFvyqTAjFPar+RjEiLX8iiJiwNZswyJsNrytmS3Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -561,16 +619,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.2.1.tgz",
|
||||
"integrity": "sha512-/BTOyRl31tvnCmoLs4qNPROMRLaG34jGYNyMQquB0uPUXZjwdMloikriwos91qCOLUrhvs4SaDpC3Ghv2BO5kA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.1.tgz",
|
||||
"integrity": "sha512-lF1gkz/a77G3+Rr8MOefkGnPJ1i5xWnClXm2ZzYAl7PbOScp59/PaP7qeU7eMPC4FHQM81ZhCgVYGXogbaB8ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -578,14 +637,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.2.1.tgz",
|
||||
"integrity": "sha512-iMfuVJqttJmm7Zb8oOaqNVNrC3NS57bDNNAc4MIc2f2TxIFSznvBPlwWN+PN45qNcTQiGzFc1ZMqIQDOG4qFnQ==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.1.tgz",
|
||||
"integrity": "sha512-c9oH097e1CVUpYF9RgZRfV/7XCJ0pf+svdT1wyM2MbWby06ti20oCwT9wf7BLY0hPQ7+eO3wunr1I1/y3MnVrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -593,14 +653,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.2.1.tgz",
|
||||
"integrity": "sha512-UhHds5donLDXm3i9nKrhSmo3yawVtjb6gID0MDrhj3+Lci/YQ3wDvGUhk7dNmgLcOt7G8pMa0wesnnpVWirUXA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.1.tgz",
|
||||
"integrity": "sha512-mRAlIW7IZAnCyDuYqN13yDc6yoNIYLUB4uYTUAR7vTIt021C8H5jDHk9TmLwcH0tQ8/R3yHuDm/XPAe0zfs81g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -608,15 +669,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.2.1.tgz",
|
||||
"integrity": "sha512-I1haDXIOzs59uhOWEP6UvP5jzjcQHMLQuQbfRVJM0zdWU6t3jwSfcwPUI7iv4CAAepbuyJKL328yc8736r/FYw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.1.tgz",
|
||||
"integrity": "sha512-mDvK3DyBZC8/8pOEdJsWtSjCmV2ZuZJJ6xfspJpsaDVywo1Vq6M55BtKThkhqED6mqbFWTN9rP9cbWG8KDBWVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -624,15 +686,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.2.1.tgz",
|
||||
"integrity": "sha512-sl9FBQzbOBtdmPpf6UI0bnWCTPWDkj47rTxyK07bpnGfGuFof4zhcxmMaFdyP7zqBh4Y9XqGu4A0uMTO2d/t7g==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.1.tgz",
|
||||
"integrity": "sha512-SLwYPQg1NJWytq2sd4MnWFmRVGgzwbohBedB2kH0ALsvdnoRYqgjR5HqAsKgoRJO/pphQhHlk3L1gLW62r6hqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-loader": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-loader": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -640,16 +703,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.2.1.tgz",
|
||||
"integrity": "sha512-wgG1X1sl6sed3pv7WLIO74SX0x3389/ax+/OLMty/LFbDNYMRO+n8ZQss8aUM700HARIqkPJy7UoSQt91o4nwA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.1.tgz",
|
||||
"integrity": "sha512-yef2XB/zR7zjyeUB3Ul0SbTcXqu5isR0GtINkFwL7bJMok6HpYNDnMXSuo55BaxI0dOCnnCSZfoRkAgosnZ1uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -657,15 +721,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.2.1.tgz",
|
||||
"integrity": "sha512-rpadnutT1wSdBQV7RQz40zYdKgCRgmJde/tamgB8oHQypcnZGQcAG6/ZfX5j12s9pG18hKwwL+KmMoBnXD9IjQ==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.1.tgz",
|
||||
"integrity": "sha512-RJ/kgJsFRdtWlPMXTW1feUSb6WHIvxtNRLgqzX8dlFIoyc4oZex2Vw+URo/VZuWSe/NvCIihQ20rkNAQJMnNMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-loader": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-loader": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -673,32 +738,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.2.1.tgz",
|
||||
"integrity": "sha512-TjHPkK8p3+FDMLcUdb3/4VREjm+liVooufLPVZ3FCXHbiC0PeUkqnwAxpCS2Jw1n+EtkY8pefRdRJeZhO6plOQ==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.1.tgz",
|
||||
"integrity": "sha512-xv96ESa7JgD5z+TzcOK18/u0gq3d9v7QPv2wpr0ZhcnwLwf4sH0eUJZIsv7z7DMOpBNz7o7jJbrtxDUdCEHGhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-render": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-render": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.2.1.tgz",
|
||||
"integrity": "sha512-C9uOGVIsoxUw+uQMXfJFZ8ibRLQeNOnaKC2izjx967iGu0ZoecAv+mKtH/Ge0vMEKYM1109AlF5T2EGwKQW2YA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.1.tgz",
|
||||
"integrity": "sha512-Q8RF80fb6y9GDAKwvgsu0BsWJlQuhNCtSKWwp3YcZJtIBFm94DVcg0zTgvDmE9/WNOmn4Z1Edt86usmYauHolw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-render": "1.2.1",
|
||||
"@embedpdf/plugin-scroll": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-render": "1.3.1",
|
||||
"@embedpdf/plugin-scroll": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -706,14 +773,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.2.1.tgz",
|
||||
"integrity": "sha512-yvftOis7FLBjM3w2VYO5LXVKXoHkmFV/SPy7U6SbuLJTX126F4ohSij9euMHJjaqOgr5tBNvrf4xemVRglxM9w==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.1.tgz",
|
||||
"integrity": "sha512-gzosrWL18ZhN175Kxocf/p7uqYBhNHvEuV1CpJQmN7ys48aew6Qq8z7MjAsCnJBANXk/8syNdo3qWwBriyjQNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -721,18 +789,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.2.1.tgz",
|
||||
"integrity": "sha512-hsp/nM4C8q0FM9P6FkpQLbU8IYawUgmiYgD3HXqHWBVRk30OIaXs4N0KC9vsHwn8ZAiyLl7jhlAXpgoacH5xEQ==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.1.tgz",
|
||||
"integrity": "sha512-3GXpgv6XmZiQnjaPbsxblTqUn84ALFiyONh2gwrEU9apB6STT3TQiY0QRindwrUXdQLpCSjRSB9PpDBCtTww7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1",
|
||||
"@embedpdf/models": "1.3.1",
|
||||
"hammerjs": "^2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-scroll": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-scroll": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.1.tgz",
|
||||
"integrity": "sha512-6trYysnggwCCTB2q7cX6tkOTbZJNtt2YYZohPCmh0yaDpkfNSgwDwD0jCLtEU2UZLQoH4+2GvNo+4xe+KAGlIQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
|
||||
+18
-15
@@ -6,21 +6,24 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.2.1",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.2.1",
|
||||
"@embedpdf/plugin-loader": "^1.2.1",
|
||||
"@embedpdf/plugin-pan": "^1.2.1",
|
||||
"@embedpdf/plugin-render": "^1.2.1",
|
||||
"@embedpdf/plugin-rotate": "^1.2.1",
|
||||
"@embedpdf/plugin-scroll": "^1.2.1",
|
||||
"@embedpdf/plugin-search": "^1.2.1",
|
||||
"@embedpdf/plugin-selection": "^1.2.1",
|
||||
"@embedpdf/plugin-spread": "^1.2.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.2.1",
|
||||
"@embedpdf/plugin-tiling": "^1.2.1",
|
||||
"@embedpdf/plugin-viewport": "^1.2.1",
|
||||
"@embedpdf/plugin-zoom": "^1.2.1",
|
||||
"@embedpdf/core": "^1.3.1",
|
||||
"@embedpdf/engines": "^1.3.1",
|
||||
"@embedpdf/plugin-annotation": "^1.3.1",
|
||||
"@embedpdf/plugin-export": "^1.3.1",
|
||||
"@embedpdf/plugin-history": "^1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.1",
|
||||
"@embedpdf/plugin-loader": "^1.3.1",
|
||||
"@embedpdf/plugin-pan": "^1.3.1",
|
||||
"@embedpdf/plugin-render": "^1.3.1",
|
||||
"@embedpdf/plugin-rotate": "^1.3.1",
|
||||
"@embedpdf/plugin-scroll": "^1.3.1",
|
||||
"@embedpdf/plugin-search": "^1.3.1",
|
||||
"@embedpdf/plugin-selection": "^1.3.1",
|
||||
"@embedpdf/plugin-spread": "^1.3.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.1",
|
||||
"@embedpdf/plugin-tiling": "^1.3.1",
|
||||
"@embedpdf/plugin-viewport": "^1.3.1",
|
||||
"@embedpdf/plugin-zoom": "^1.3.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"keepWorking": "Keep Working",
|
||||
"discardChanges": "Discard Changes",
|
||||
"applyAndContinue": "Apply & Continue",
|
||||
"exportAndContinue": "Export & Continue",
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
@@ -74,7 +80,10 @@
|
||||
},
|
||||
"error": {
|
||||
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
|
||||
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
|
||||
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
|
||||
"_value": "Error",
|
||||
"dismissAllErrors": "Dismiss All Errors",
|
||||
"sorry": "Sorry for the issue!",
|
||||
"needHelp": "Need help / Found an issue?",
|
||||
"contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:",
|
||||
@@ -357,222 +366,276 @@
|
||||
"globalPopularity": "Global Popularity",
|
||||
"sortBy": "Sort by:",
|
||||
"multiTool": {
|
||||
"tags": "multiple,tools",
|
||||
"title": "PDF Multi Tool",
|
||||
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
|
||||
},
|
||||
"merge": {
|
||||
"tags": "combine,join,unite",
|
||||
"title": "Merge",
|
||||
"desc": "Easily merge multiple PDFs into one."
|
||||
},
|
||||
"split": {
|
||||
"tags": "divide,separate,break",
|
||||
"title": "Split",
|
||||
"desc": "Split PDFs into multiple documents"
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "turn,flip,orient",
|
||||
"title": "Rotate",
|
||||
"desc": "Easily rotate your PDFs."
|
||||
},
|
||||
"convert": {
|
||||
"tags": "transform,change",
|
||||
"title": "Convert",
|
||||
"desc": "Convert files between different formats"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"tags": "organize,rearrange,reorder",
|
||||
"title": "Organise",
|
||||
"desc": "Remove/Rearrange pages in any order"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "insert,embed,place",
|
||||
"title": "Add image",
|
||||
"desc": "Adds a image onto a set location on the PDF"
|
||||
},
|
||||
"addAttachments": {
|
||||
"tags": "embed,attach,include",
|
||||
"title": "Add Attachments",
|
||||
"desc": "Add or remove embedded files (attachments) to/from a PDF"
|
||||
},
|
||||
"watermark": {
|
||||
"tags": "stamp,mark,overlay",
|
||||
"title": "Add Watermark",
|
||||
"desc": "Add a custom watermark to your PDF document."
|
||||
},
|
||||
"removePassword": {
|
||||
"tags": "unlock",
|
||||
"title": "Remove Password",
|
||||
"desc": "Remove password protection from your PDF document."
|
||||
},
|
||||
"compress": {
|
||||
"tags": "shrink,reduce,optimize",
|
||||
"title": "Compress",
|
||||
"desc": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"tags": "unlock,enable,edit",
|
||||
"title": "Unlock PDF Forms",
|
||||
"desc": "Remove read-only property of form fields in a PDF document."
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "edit,modify,update",
|
||||
"title": "Change Metadata",
|
||||
"desc": "Change/Remove/Add metadata from a PDF document"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"extractImages": {
|
||||
"tags": "pull,save,export",
|
||||
"title": "Extract Images",
|
||||
"desc": "Extracts all images from a PDF and saves them to zip"
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Detect/Split Scanned photos",
|
||||
"desc": "Splits multiple photos from within a photo/PDF"
|
||||
"tags": "detect,split,photos",
|
||||
"title": "Detect & Split Scanned Photos",
|
||||
"desc": "Detect and split scanned photos into separate pages"
|
||||
},
|
||||
"sign": {
|
||||
"tags": "signature,autograph",
|
||||
"title": "Sign",
|
||||
"desc": "Adds signature to PDF by drawing, text or image"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "simplify,remove,interactive",
|
||||
"title": "Flatten",
|
||||
"desc": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"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)"
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore",
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"tags": "delete,clean,empty",
|
||||
"title": "Remove Blank pages",
|
||||
"desc": "Detects and removes blank pages from a document"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "delete,clean,strip",
|
||||
"title": "Remove Annotations",
|
||||
"desc": "Removes all comments/annotations from a PDF"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "difference",
|
||||
"title": "Compare",
|
||||
"desc": "Compares and shows the differences between 2 PDF Documents"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"tags": "remove,delete,unlock",
|
||||
"title": "Remove Certificate Sign",
|
||||
"desc": "Remove certificate signature from PDF"
|
||||
},
|
||||
"pageLayout": {
|
||||
"tags": "layout,arrange,combine",
|
||||
"title": "Multi-Page Layout",
|
||||
"desc": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,print,binding",
|
||||
"title": "Booklet Imposition",
|
||||
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,adjust,scale",
|
||||
"title": "Adjust page size/scale",
|
||||
"desc": "Change the size/scale of a page and/or its contents."
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"tags": "number,pagination,count",
|
||||
"title": "Add Page Numbers",
|
||||
"desc": "Add Page numbers throughout a document in a set location"
|
||||
},
|
||||
"autoRename": {
|
||||
"tags": "auto-detect,header-based,organize,relabel",
|
||||
"title": "Auto Rename PDF File",
|
||||
"desc": "Auto renames a PDF file based on its detected header"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colours/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
"title": "Crop PDF",
|
||||
"desc": "Crop a PDF to reduce its size (maintains text!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"tags": "auto,split,QR",
|
||||
"title": "Auto Split Pages",
|
||||
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
|
||||
},
|
||||
"sanitize": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitise",
|
||||
"desc": "Remove potentially harmful elements from PDF files"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"tags": "info,metadata,details",
|
||||
"title": "Get ALL Info on PDF",
|
||||
"desc": "Grabs any and all information possible on PDFs"
|
||||
},
|
||||
"pdfToSinglePage": {
|
||||
"tags": "combine,merge,single",
|
||||
"title": "PDF to Single Large Page",
|
||||
"desc": "Merges all PDF pages into one large single page"
|
||||
},
|
||||
"showJS": {
|
||||
"tags": "javascript,code,script",
|
||||
"title": "Show Javascript",
|
||||
"desc": "Searches and displays any JS injected into a PDF"
|
||||
},
|
||||
"redact": {
|
||||
"tags": "censor,blackout,hide",
|
||||
"title": "Redact",
|
||||
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
},
|
||||
"overlayPdfs": {
|
||||
"tags": "overlay,combine,stack",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlays PDFs on-top of another PDF"
|
||||
},
|
||||
"splitBySections": {
|
||||
"tags": "split,sections,divide",
|
||||
"title": "Split PDF by Sections",
|
||||
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
|
||||
},
|
||||
"addStamp": {
|
||||
"tags": "stamp,mark,seal",
|
||||
"title": "Add Stamp to PDF",
|
||||
"desc": "Add text or add image stamps at set locations"
|
||||
},
|
||||
"removeImage": {
|
||||
"tags": "remove,delete,clean",
|
||||
"title": "Remove image",
|
||||
"desc": "Remove image from PDF to reduce file size"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"tags": "split,chapters,structure",
|
||||
"title": "Split PDF by Chapters",
|
||||
"desc": "Split a PDF into multiple files based on its chapter structure."
|
||||
},
|
||||
"validateSignature": {
|
||||
"tags": "validate,verify,certificate",
|
||||
"title": "Validate PDF Signature",
|
||||
"desc": "Verify digital signatures and certificates in PDF documents"
|
||||
},
|
||||
"swagger": {
|
||||
"tags": "API,documentation,test",
|
||||
"title": "API Documentation",
|
||||
"desc": "View API documentation and test endpoints"
|
||||
},
|
||||
"fakeScan": {
|
||||
"title": "Fake Scan",
|
||||
"scannerEffect": {
|
||||
"tags": "scan,simulate,create",
|
||||
"title": "Scanner Effect",
|
||||
"desc": "Create a PDF that looks like it was scanned"
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"tags": "bookmarks,contents,edit",
|
||||
"title": "Edit Table of Contents",
|
||||
"desc": "Add or edit bookmarks and table of contents in PDF documents"
|
||||
},
|
||||
"manageCertificates": {
|
||||
"tags": "certificates,import,export",
|
||||
"title": "Manage Certificates",
|
||||
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
|
||||
},
|
||||
"read": {
|
||||
"title": "Read",
|
||||
"read": {
|
||||
"tags": "view,open,display",
|
||||
"title": "Read",
|
||||
"desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
|
||||
},
|
||||
"reorganizePages": {
|
||||
"tags": "rearrange,reorder,organize",
|
||||
"title": "Reorganize Pages",
|
||||
"desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
|
||||
},
|
||||
"extractPages": {
|
||||
"tags": "pull,select,copy",
|
||||
"title": "Extract Pages",
|
||||
"desc": "Extract specific pages from a PDF document"
|
||||
},
|
||||
"removePages": {
|
||||
"tags": "delete,extract,exclude",
|
||||
"title": "Remove Pages",
|
||||
"desc": "Remove specific pages from a PDF document"
|
||||
},
|
||||
"autoSizeSplitPDF": {
|
||||
"tags": "auto,split,size",
|
||||
"title": "Auto Split by Size/Count",
|
||||
"desc": "Automatically split PDFs by file size or page count"
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"replaceColor": {
|
||||
"title": "Replace & Invert Colour",
|
||||
"desc": "Replace or invert colours in PDF documents"
|
||||
},
|
||||
"devApi": {
|
||||
"tags": "API,development,documentation",
|
||||
"title": "API",
|
||||
"desc": "Link to API documentation"
|
||||
},
|
||||
"devFolderScanning": {
|
||||
"tags": "automation,folder,scanning",
|
||||
"title": "Automated Folder Scanning",
|
||||
"desc": "Link to automated folder scanning guide"
|
||||
},
|
||||
@@ -593,6 +656,7 @@
|
||||
"desc": "Change document restrictions and permissions"
|
||||
},
|
||||
"automate": {
|
||||
"tags": "workflow,sequence,automation",
|
||||
"title": "Automate",
|
||||
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
|
||||
}
|
||||
@@ -659,7 +723,6 @@
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"tags": "Page operations,divide,Multi Page,cut,server side",
|
||||
"title": "Split PDF",
|
||||
"header": "Split PDF",
|
||||
"desc": {
|
||||
@@ -785,7 +848,6 @@
|
||||
}
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "server side",
|
||||
"title": "Rotate PDF",
|
||||
"submit": "Apply Rotation",
|
||||
"error": {
|
||||
@@ -902,7 +964,7 @@
|
||||
"header": "PDF Page Organiser",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Mode",
|
||||
"_value": "Organization mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@@ -915,6 +977,19 @@
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimised for binding on the side).",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
|
||||
},
|
||||
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
@@ -1298,7 +1373,6 @@
|
||||
}
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "Title,author,date,creation,time,publisher,producer,stats",
|
||||
"header": "Change Metadata",
|
||||
"submit": "Change",
|
||||
"filenamePrefix": "metadata",
|
||||
@@ -1540,7 +1614,13 @@
|
||||
"header": "Extract Images",
|
||||
"selectText": "Select image format to convert extracted images to",
|
||||
"allowDuplicates": "Save duplicate images",
|
||||
"submit": "Extract"
|
||||
"submit": "Extract",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting images from the PDF."
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archive,long-term,standard,conversion,storage,preservation",
|
||||
@@ -1600,20 +1680,49 @@
|
||||
"tags": "separate,auto-detect,scans,multi-photo,organize",
|
||||
"selectText": {
|
||||
"1": "Angle Threshold:",
|
||||
"2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).",
|
||||
"2": "Tilt (in degrees) needed before we auto-straighten a photo.",
|
||||
"3": "Tolerance:",
|
||||
"4": "Determines the range of colour variation around the estimated background colour (default: 30).",
|
||||
"4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.",
|
||||
"5": "Minimum Area:",
|
||||
"6": "Sets the minimum area threshold for a photo (default: 10000).",
|
||||
"6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.",
|
||||
"7": "Minimum Contour Area:",
|
||||
"8": "Sets the minimum contour area threshold for a photo",
|
||||
"8": "Smallest edge/shape we consider when finding photos (filters dust and specks).",
|
||||
"9": "Border Size:",
|
||||
"10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)."
|
||||
"10": "Extra padding (in pixels) around each saved photo so edges aren't cut."
|
||||
},
|
||||
"info": "Python is not installed. It is required to run."
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Extracted Images",
|
||||
"submit": "Extract Image Scans",
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting image scans."
|
||||
},
|
||||
"tooltip": {
|
||||
"title": "Photo Splitter",
|
||||
"whatThisDoes": "What this does",
|
||||
"whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.",
|
||||
"whenToUse": "When to use",
|
||||
"useCase1": "Scan whole album pages in one go",
|
||||
"useCase2": "Split flatbed batches into separate files",
|
||||
"useCase3": "Break collages into individual photos",
|
||||
"useCase4": "Pull photos from documents",
|
||||
"quickFixes": "Quick fixes",
|
||||
"problem1": "Photos not detected → increase Tolerance to 30-50",
|
||||
"problem2": "Too many false detections → increase Minimum Area to 15,000-20,000",
|
||||
"problem3": "Crops are too tight → increase Border Size to 5-10",
|
||||
"problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°",
|
||||
"problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000",
|
||||
"setupTips": "Setup tips",
|
||||
"tip1": "Use a plain, light background",
|
||||
"tip2": "Leave a small gap (≈1 cm) between photos",
|
||||
"tip3": "Scan at 300-600 DPI",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
"upload": "Upload Image",
|
||||
@@ -1623,6 +1732,7 @@
|
||||
"add": "Add",
|
||||
"saved": "Saved Signatures",
|
||||
"save": "Save Signature",
|
||||
"applySignatures": "Apply Signatures",
|
||||
"personalSigs": "Personal Signatures",
|
||||
"sharedSigs": "Shared Signatures",
|
||||
"noSavedSigs": "No saved signatures found",
|
||||
@@ -1634,10 +1744,44 @@
|
||||
"previous": "Previous page",
|
||||
"maintainRatio": "Toggle maintain aspect ratio",
|
||||
"undo": "Undo",
|
||||
"redo": "Redo"
|
||||
"redo": "Redo",
|
||||
"submit": "Sign Document",
|
||||
"steps": {
|
||||
"configure": "Configure Signature"
|
||||
},
|
||||
"type": {
|
||||
"title": "Signature Type",
|
||||
"draw": "Draw",
|
||||
"canvas": "Canvas",
|
||||
"image": "Image",
|
||||
"text": "Text"
|
||||
},
|
||||
"draw": {
|
||||
"title": "Draw your signature",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"image": {
|
||||
"label": "Upload signature image",
|
||||
"placeholder": "Select image file",
|
||||
"hint": "Upload a PNG or JPG image of your signature"
|
||||
},
|
||||
"text": {
|
||||
"name": "Signer Name",
|
||||
"placeholder": "Enter your full name"
|
||||
},
|
||||
"instructions": {
|
||||
"title": "How to add signature"
|
||||
},
|
||||
"activate": "Activate Signature Placement",
|
||||
"deactivate": "Stop Placing Signatures",
|
||||
"results": {
|
||||
"title": "Signature Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while signing the PDF."
|
||||
}
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "static,deactivate,non-interactive,streamline",
|
||||
"title": "Flatten",
|
||||
"header": "Flatten PDF",
|
||||
"flattenOnlyForms": "Flatten only forms",
|
||||
@@ -1702,7 +1846,6 @@
|
||||
}
|
||||
},
|
||||
"removeBlanks": {
|
||||
"tags": "cleanup,streamline,non-content,organize",
|
||||
"title": "Remove Blanks",
|
||||
"header": "Remove Blank Pages",
|
||||
"settings": {
|
||||
@@ -1750,7 +1893,17 @@
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
"title": "Remove Annotations",
|
||||
"header": "Remove Annotations",
|
||||
"submit": "Remove"
|
||||
"submit": "Remove",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"info": {
|
||||
"title": "About Remove Annotations",
|
||||
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing annotations from the PDF."
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
@@ -2099,7 +2252,6 @@
|
||||
"tags": "color-correction,tune,modify,enhance,colour-correction"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,shrink,edit,shape",
|
||||
"title": "Crop",
|
||||
"header": "Crop PDF",
|
||||
"submit": "Apply Crop",
|
||||
@@ -2451,25 +2603,48 @@
|
||||
},
|
||||
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
|
||||
},
|
||||
"replace-color": {
|
||||
"title": "Advanced Colour options",
|
||||
"header": "Replace-Invert Colour PDF",
|
||||
"selectText": {
|
||||
"1": "Replace or Invert colour Options",
|
||||
"2": "Default(Default high contrast colours)",
|
||||
"3": "Custom(Customised colours)",
|
||||
"4": "Full-Invert(Invert all colours)",
|
||||
"5": "High contrast colour options",
|
||||
"6": "white text on black background",
|
||||
"7": "Black text on white background",
|
||||
"8": "Yellow text on black background",
|
||||
"9": "Green text on black background",
|
||||
"10": "Choose text Colour",
|
||||
"11": "Choose background Colour"
|
||||
"replaceColor": {
|
||||
"labels": {
|
||||
"settings": "Settings",
|
||||
"colourOperation": "Colour operation"
|
||||
},
|
||||
"submit": "Replace"
|
||||
"options": {
|
||||
"highContrast": "High contrast",
|
||||
"invertAll": "Invert all colours",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Replace & Invert Colour Settings Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes."
|
||||
},
|
||||
"highContrast": {
|
||||
"title": "High Contrast",
|
||||
"text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.",
|
||||
"bullet1": "White text on black background - Classic dark mode",
|
||||
"bullet2": "Black text on white background - Standard high contrast",
|
||||
"bullet3": "Yellow text on black background - High visibility option",
|
||||
"bullet4": "Green text on black background - Alternative high contrast"
|
||||
},
|
||||
"invertAll": {
|
||||
"title": "Invert All Colours",
|
||||
"text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
|
||||
},
|
||||
"custom": {
|
||||
"title": "Custom Colours",
|
||||
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
|
||||
"bullet1": "Text colour - Choose the colour for text elements",
|
||||
"bullet2": "Background colour - Set the background colour for the document"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while processing the colour replacement."
|
||||
}
|
||||
},
|
||||
"replaceColorPdf": {
|
||||
"replaceColor": {
|
||||
"tags": "Replace Colour,Page operations,Back end,server side"
|
||||
},
|
||||
"login": {
|
||||
@@ -3246,6 +3421,16 @@
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
}
|
||||
},
|
||||
"viewer": {
|
||||
"firstPage": "First Page",
|
||||
"lastPage": "Last Page",
|
||||
"previousPage": "Previous Page",
|
||||
"nextPage": "Next Page",
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"singlePageView": "Single Page View",
|
||||
"dualPageView": "Dual Page View"
|
||||
},
|
||||
"common": {
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
@@ -3302,6 +3487,18 @@
|
||||
"generateError": "We couldn't generate your API key."
|
||||
}
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"attachments": "Select Attachments",
|
||||
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
|
||||
"selectFiles": "Select Files to Attach",
|
||||
"placeholder": "Choose files...",
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
}
|
||||
},
|
||||
"termsAndConditions": "Terms & Conditions",
|
||||
"logOut": "Log out"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@
|
||||
},
|
||||
"error": {
|
||||
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
|
||||
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
|
||||
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
|
||||
"_value": "Error",
|
||||
"sorry": "Sorry for the issue!",
|
||||
"needHelp": "Need help / Found an issue?",
|
||||
@@ -348,206 +350,257 @@
|
||||
"globalPopularity": "Global Popularity",
|
||||
"sortBy": "Sort by:",
|
||||
"multiTool": {
|
||||
"tags": "multiple,tools",
|
||||
"title": "PDF Multi Tool",
|
||||
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
|
||||
},
|
||||
"merge": {
|
||||
"tags": "combine,join,unite",
|
||||
"title": "Merge",
|
||||
"desc": "Easily merge multiple PDFs into one."
|
||||
},
|
||||
"split": {
|
||||
"tags": "divide,separate,break",
|
||||
"title": "Split",
|
||||
"desc": "Split PDFs into multiple documents"
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "turn,flip,orient",
|
||||
"title": "Rotate",
|
||||
"desc": "Easily rotate your PDFs."
|
||||
},
|
||||
"imageToPDF": {
|
||||
"tags": "convert,image,transform",
|
||||
"title": "Image to PDF",
|
||||
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
|
||||
},
|
||||
"pdfToImage": {
|
||||
"tags": "convert,image,extract",
|
||||
"title": "PDF to Image",
|
||||
"desc": "Convert a PDF to a image. (PNG, JPEG, GIF)"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"tags": "organize,rearrange,reorder",
|
||||
"title": "Organize",
|
||||
"desc": "Remove/Rearrange pages in any order"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "insert,embed,place",
|
||||
"title": "Add image",
|
||||
"desc": "Adds a image onto a set location on the PDF"
|
||||
},
|
||||
"watermark": {
|
||||
"tags": "stamp,mark,overlay",
|
||||
"title": "Add Watermark",
|
||||
"desc": "Add a custom watermark to your PDF document."
|
||||
},
|
||||
"permissions": {
|
||||
"tags": "permissions,security,access",
|
||||
"title": "Change Permissions",
|
||||
"desc": "Change the permissions of your PDF document"
|
||||
},
|
||||
"pageRemover": {
|
||||
"tags": "remove,delete,pages",
|
||||
"title": "Remove",
|
||||
"desc": "Delete unwanted pages from your PDF document."
|
||||
},
|
||||
"addPassword": {
|
||||
"tags": "password,encrypt,secure",
|
||||
"title": "Add Password",
|
||||
"desc": "Encrypt your PDF document with a password."
|
||||
},
|
||||
"changePermissions": {
|
||||
"tags": "permissions,restrictions,security",
|
||||
"title": "Change Permissions",
|
||||
"desc": "Change document restrictions and permissions."
|
||||
},
|
||||
"removePassword": {
|
||||
"tags": "unlock,remove,password",
|
||||
"title": "Remove Password",
|
||||
"desc": "Remove password protection from your PDF document."
|
||||
},
|
||||
"compress": {
|
||||
"tags": "shrink,reduce,optimize",
|
||||
"title": "Compress",
|
||||
"desc": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"sanitize": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitize",
|
||||
"desc": "Remove potentially harmful elements from PDF files."
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"tags": "unlock,enable,edit",
|
||||
"title": "Unlock PDF Forms",
|
||||
"desc": "Remove read-only property of form fields in a PDF document."
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "edit,modify,update",
|
||||
"title": "Change Metadata",
|
||||
"desc": "Change/Remove/Add metadata from a PDF document"
|
||||
},
|
||||
"fileToPDF": {
|
||||
"tags": "convert,transform,change",
|
||||
"title": "Convert file to PDF",
|
||||
"desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"extractImages": {
|
||||
"tags": "pull,save,export",
|
||||
"title": "Extract Images",
|
||||
"desc": "Extracts all images from a PDF and saves them to zip"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "convert,archive,long-term",
|
||||
"title": "PDF to PDF/A",
|
||||
"desc": "Convert PDF to PDF/A for long-term storage"
|
||||
},
|
||||
"PDFToWord": {
|
||||
"tags": "convert,word,doc",
|
||||
"title": "PDF to Word",
|
||||
"desc": "Convert PDF to Word formats (DOC, DOCX and ODT)"
|
||||
},
|
||||
"PDFToPresentation": {
|
||||
"tags": "convert,presentation,ppt",
|
||||
"title": "PDF to Presentation",
|
||||
"desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)"
|
||||
},
|
||||
"PDFToText": {
|
||||
"tags": "convert,text,rtf",
|
||||
"title": "PDF to RTF (Text)",
|
||||
"desc": "Convert PDF to Text or RTF format"
|
||||
},
|
||||
"PDFToHTML": {
|
||||
"tags": "convert,html,web",
|
||||
"title": "PDF to HTML",
|
||||
"desc": "Convert PDF to HTML format"
|
||||
},
|
||||
"PDFToXML": {
|
||||
"tags": "convert,xml,data",
|
||||
"title": "PDF to XML",
|
||||
"desc": "Convert PDF to XML format"
|
||||
},
|
||||
"ScannerImageSplit": {
|
||||
"tags": "detect,split,photos",
|
||||
"title": "Detect/Split Scanned photos",
|
||||
"desc": "Splits multiple photos from within a photo/PDF"
|
||||
},
|
||||
"sign": {
|
||||
"tags": "signature,autograph",
|
||||
"title": "Sign",
|
||||
"desc": "Adds signature to PDF by drawing, text or image"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "simplify,remove,interactive",
|
||||
"title": "Flatten",
|
||||
"desc": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore",
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"tags": "delete,clean,empty",
|
||||
"title": "Remove Blank pages",
|
||||
"desc": "Detects and removes blank pages from a document"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "delete,clean,strip",
|
||||
"title": "Remove Annotations",
|
||||
"desc": "Removes all comments/annotations from a PDF"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "difference",
|
||||
"title": "Compare",
|
||||
"desc": "Compares and shows the differences between 2 PDF Documents"
|
||||
},
|
||||
"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)"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"tags": "remove,delete,unlock",
|
||||
"title": "Remove Certificate Sign",
|
||||
"desc": "Remove certificate signature from PDF"
|
||||
},
|
||||
"pageLayout": {
|
||||
"tags": "layout,arrange,combine",
|
||||
"title": "Multi-Page Layout",
|
||||
"desc": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,print,binding",
|
||||
"title": "Booklet Imposition",
|
||||
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,adjust,scale",
|
||||
"title": "Adjust page size/scale",
|
||||
"desc": "Change the size/scale of a page and/or its contents."
|
||||
},
|
||||
"pipeline": {
|
||||
"tags": "automation,script,workflow",
|
||||
"title": "Pipeline",
|
||||
"desc": "Run multiple actions on PDFs by defining pipeline scripts"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"tags": "number,pagination,count",
|
||||
"title": "Add Page Numbers",
|
||||
"desc": "Add Page numbers throughout a document in a set location"
|
||||
},
|
||||
"auto-rename": {
|
||||
"tags": "auto-detect,header-based,organize,relabel",
|
||||
"title": "Auto Rename PDF File",
|
||||
"desc": "Auto renames a PDF file based on its detected header"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
"title": "Crop PDF",
|
||||
"desc": "Crop a PDF to reduce its size (maintains text!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"tags": "auto,split,QR",
|
||||
"title": "Auto Split Pages",
|
||||
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
|
||||
},
|
||||
"sanitizePDF": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitize",
|
||||
"desc": "Remove scripts and other elements from PDF files"
|
||||
},
|
||||
"URLToPDF": {
|
||||
"tags": "convert,url,website",
|
||||
"title": "URL/Website To PDF",
|
||||
"desc": "Converts any http(s)URL to PDF"
|
||||
},
|
||||
"HTMLToPDF": {
|
||||
"tags": "convert,html,web",
|
||||
"title": "HTML to PDF",
|
||||
"desc": "Converts any HTML file or zip to PDF"
|
||||
},
|
||||
"MarkdownToPDF": {
|
||||
"tags": "convert,markdown,md",
|
||||
"title": "Markdown to PDF",
|
||||
"desc": "Converts any Markdown file to PDF"
|
||||
},
|
||||
"PDFToMarkdown": {
|
||||
"tags": "convert,markdown,md",
|
||||
"title": "PDF to Markdown",
|
||||
"desc": "Converts any PDF to Markdown"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"tags": "info,metadata,details",
|
||||
"title": "Get ALL Info on PDF",
|
||||
"desc": "Grabs any and all information possible on PDFs"
|
||||
},
|
||||
@@ -564,50 +617,62 @@
|
||||
"desc": "Searches and displays any JS injected into a PDF"
|
||||
},
|
||||
"autoRedact": {
|
||||
"tags": "auto,redact,censor",
|
||||
"title": "Auto Redact",
|
||||
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
|
||||
},
|
||||
"redact": {
|
||||
"tags": "censor,blackout,hide",
|
||||
"title": "Manual Redaction",
|
||||
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
},
|
||||
"PDFToCSV": {
|
||||
"tags": "convert,csv,table",
|
||||
"title": "PDF to CSV",
|
||||
"desc": "Extracts Tables from a PDF converting it to CSV"
|
||||
},
|
||||
"split-by-size-or-count": {
|
||||
"tags": "auto,split,size",
|
||||
"title": "Auto Split by Size/Count",
|
||||
"desc": "Split a single PDF into multiple documents based on size, page count, or document count"
|
||||
},
|
||||
"overlay-pdfs": {
|
||||
"tags": "overlay,combine,stack",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlays PDFs on-top of another PDF"
|
||||
},
|
||||
"split-by-sections": {
|
||||
"tags": "split,sections,divide",
|
||||
"title": "Split PDF by Sections",
|
||||
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
|
||||
},
|
||||
"AddStampRequest": {
|
||||
"tags": "stamp,mark,seal",
|
||||
"title": "Add Stamp to PDF",
|
||||
"desc": "Add text or add image stamps at set locations"
|
||||
},
|
||||
"removeImage": {
|
||||
"tags": "remove,delete,clean",
|
||||
"title": "Remove image",
|
||||
"desc": "Remove image from PDF to reduce file size"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"tags": "split,chapters,structure",
|
||||
"title": "Split PDF by Chapters",
|
||||
"desc": "Split a PDF into multiple files based on its chapter structure."
|
||||
},
|
||||
"validateSignature": {
|
||||
"tags": "validate,verify,certificate",
|
||||
"title": "Validate PDF Signature",
|
||||
"desc": "Verify digital signatures and certificates in PDF documents"
|
||||
},
|
||||
"swagger": {
|
||||
"tags": "API,documentation,test",
|
||||
"title": "API Documentation",
|
||||
"desc": "View API documentation and test endpoints"
|
||||
},
|
||||
"replace-color": {
|
||||
"tags": "color,replace,invert",
|
||||
"title": "Replace and Invert Color",
|
||||
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
|
||||
}
|
||||
@@ -704,7 +769,7 @@
|
||||
"header": "PDF Page Organizer",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Mode",
|
||||
"_value": "Organization mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@@ -717,6 +782,19 @@
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side).",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
|
||||
},
|
||||
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
@@ -991,7 +1069,13 @@
|
||||
"header": "Extract Images",
|
||||
"selectText": "Select image format to convert extracted images to",
|
||||
"allowDuplicates": "Save duplicate images",
|
||||
"submit": "Extract"
|
||||
"submit": "Extract",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting images from the PDF."
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archive,long-term,standard,conversion,storage,preservation",
|
||||
@@ -1064,7 +1148,6 @@
|
||||
"info": "Python is not installed. It is required to run."
|
||||
},
|
||||
"sign": {
|
||||
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
"upload": "Upload Image",
|
||||
@@ -1160,7 +1243,17 @@
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
"title": "Remove Annotations",
|
||||
"header": "Remove Annotations",
|
||||
"submit": "Remove"
|
||||
"submit": "Remove",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"info": {
|
||||
"title": "About Remove Annotations",
|
||||
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing annotations from the PDF."
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
@@ -1740,10 +1833,16 @@
|
||||
}
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Remove image",
|
||||
"header": "Remove image",
|
||||
"removeImage": "Remove image",
|
||||
"submit": "Remove image"
|
||||
"title": "Remove Images",
|
||||
"header": "Remove Images",
|
||||
"removeImage": "Remove Images",
|
||||
"submit": "Remove Images",
|
||||
"results": {
|
||||
"title": "Remove Images Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "Failed to remove images from the PDF."
|
||||
}
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "Split PDF by Chapters",
|
||||
@@ -1788,7 +1887,7 @@
|
||||
"title": "How we use Cookies",
|
||||
"description": {
|
||||
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
|
||||
"2": "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
},
|
||||
"acceptAllBtn": "Okay",
|
||||
"acceptNecessaryBtn": "No Thanks",
|
||||
@@ -1812,7 +1911,7 @@
|
||||
"1": "Strictly Necessary Cookies",
|
||||
"2": "Always Enabled"
|
||||
},
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off."
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
@@ -2292,5 +2391,22 @@
|
||||
},
|
||||
"automate": {
|
||||
"copyToSaved": "Copy to Saved"
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"attachments": "Select Attachments",
|
||||
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
|
||||
"selectFiles": "Select Files to Attach",
|
||||
"placeholder": "Choose files...",
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
}
|
||||
},
|
||||
"addAttachments": {
|
||||
"error": {
|
||||
"failed": "An error occurred while adding attachments to the PDF."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const { execSync } = require('node:child_process');
|
||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
import { argv } from 'node:process';
|
||||
const { argv } = require('node:process');
|
||||
const inputIdx = argv.indexOf('--input');
|
||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// __dirname is available in CommonJS by default
|
||||
|
||||
/**
|
||||
* Generate 3rd party licenses for frontend dependencies
|
||||
|
||||
@@ -14,6 +14,7 @@ import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||
import { SignatureProvider } from "./contexts/SignatureContext";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
@@ -45,9 +46,11 @@ export default function App() {
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
|
||||
@@ -7,6 +7,132 @@
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/core",
|
||||
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/engines",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-annotation",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-export",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-history",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-interaction-manager",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-loader",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-pan",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-render",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-rotate",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-scroll",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-search",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-selection",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-spread",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-thumbnail",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-tiling",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-viewport",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-zoom",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@emotion/react",
|
||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||
@@ -35,6 +161,13 @@
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dates",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dropzone",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
@@ -143,7 +276,7 @@
|
||||
{
|
||||
"moduleName": "posthog-js",
|
||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||
"moduleVersion": "1.266.0",
|
||||
"moduleVersion": "1.268.0",
|
||||
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface PDFAnnotationContextValue {
|
||||
// Drawing mode management
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
|
||||
// Drawing settings
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
|
||||
// History operations
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
|
||||
// Image data management
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
|
||||
// Placement state
|
||||
isPlacementMode: boolean;
|
||||
|
||||
// Signature configuration
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
|
||||
|
||||
interface PDFAnnotationProviderProps {
|
||||
children: ReactNode;
|
||||
// These would come from the signature context
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
isPlacementMode: boolean;
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
children,
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
}) => {
|
||||
const contextValue: PDFAnnotationContextValue = {
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFAnnotationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PDFAnnotationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||
const context = useContext(PDFAnnotationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from './DrawingControls';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { usePDFAnnotation } from '../providers/PDFAnnotationProvider';
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
enableImageUpload?: boolean;
|
||||
enableTextInput?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
interface BaseAnnotationToolProps {
|
||||
config: AnnotationToolConfig;
|
||||
children: React.ReactNode;
|
||||
onSignatureDataChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
config,
|
||||
children,
|
||||
onSignatureDataChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
activateSignaturePlacementMode,
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
setSignatureData(data);
|
||||
onSignatureDataChange?.(data);
|
||||
};
|
||||
|
||||
const handlePlaceSignature = () => {
|
||||
if (activateSignaturePlacementMode) {
|
||||
activateSignaturePlacementMode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Drawing Controls (Undo/Redo/Place) */}
|
||||
<DrawingControls
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
showPlaceButton={config.showPlaceButton}
|
||||
placeButtonText={config.placeButtonText}
|
||||
/>
|
||||
|
||||
{/* Tool Content */}
|
||||
{React.cloneElement(children as React.ReactElement<any>, {
|
||||
selectedColor,
|
||||
signatureData,
|
||||
onSignatureDataChange: handleSignatureDataChange,
|
||||
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
||||
disabled
|
||||
})}
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||
<Text size="sm">
|
||||
Click anywhere on the PDF to place your annotation.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedColor: string;
|
||||
onColorChange: (color: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title = "Choose Color"
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
swatchesPerRow={6}
|
||||
size="lg"
|
||||
fullWidth
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ColorSwatchButtonProps {
|
||||
color: string;
|
||||
onClick: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
color,
|
||||
onClick,
|
||||
size = 24
|
||||
}) => {
|
||||
return (
|
||||
<ColorSwatch
|
||||
color={color}
|
||||
size={size}
|
||||
radius={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
|
||||
import { ColorSwatchButton } from './ColorPicker';
|
||||
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
selectedColor: string;
|
||||
penSize: number;
|
||||
penSizeInput: string;
|
||||
onColorSwatchClick: () => void;
|
||||
onPenSizeChange: (size: number) => void;
|
||||
onPenSizeInputChange: (input: string) => void;
|
||||
onSignatureDataChange: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
modalWidth?: number;
|
||||
modalHeight?: number;
|
||||
additionalButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
selectedColor,
|
||||
penSize,
|
||||
penSizeInput,
|
||||
onColorSwatchClick,
|
||||
onPenSizeChange,
|
||||
onPenSizeInputChange,
|
||||
onSignatureDataChange,
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
modalWidth = 800,
|
||||
modalHeight = 400,
|
||||
additionalButtons
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const visibleModalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [isModalDrawing, setIsModalDrawing] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// Drawing functions for main canvas
|
||||
const startDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!canvasRef.current || disabled) return;
|
||||
|
||||
setIsDrawing(true);
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const scaleX = canvasRef.current.width / rect.width;
|
||||
const scaleY = canvasRef.current.height / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.strokeStyle = selectedColor;
|
||||
ctx.lineWidth = penSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
}
|
||||
}, [disabled, selectedColor, penSize]);
|
||||
|
||||
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || !canvasRef.current || disabled) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const scaleX = canvasRef.current.width / rect.width;
|
||||
const scaleY = canvasRef.current.height / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [isDrawing, disabled]);
|
||||
|
||||
const stopDrawing = useCallback(() => {
|
||||
if (!isDrawing || disabled) return;
|
||||
|
||||
setIsDrawing(false);
|
||||
|
||||
// Save canvas as signature data
|
||||
if (canvasRef.current) {
|
||||
const dataURL = canvasRef.current.toDataURL('image/png');
|
||||
onSignatureDataChange(dataURL);
|
||||
}
|
||||
}, [isDrawing, disabled, onSignatureDataChange]);
|
||||
|
||||
// Modal canvas drawing functions
|
||||
const startModalDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!visibleModalCanvasRef.current || !modalCanvasRef.current) return;
|
||||
|
||||
setIsModalDrawing(true);
|
||||
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
|
||||
const scaleX = visibleModalCanvasRef.current.width / rect.width;
|
||||
const scaleY = visibleModalCanvasRef.current.height / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
// Draw on both the visible modal canvas and hidden canvas
|
||||
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||
const hiddenCtx = modalCanvasRef.current.getContext('2d');
|
||||
|
||||
[visibleCtx, hiddenCtx].forEach(ctx => {
|
||||
if (ctx) {
|
||||
ctx.strokeStyle = selectedColor;
|
||||
ctx.lineWidth = penSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
}
|
||||
});
|
||||
}, [selectedColor, penSize]);
|
||||
|
||||
const drawModal = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isModalDrawing || !visibleModalCanvasRef.current || !modalCanvasRef.current) return;
|
||||
|
||||
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
|
||||
const scaleX = visibleModalCanvasRef.current.width / rect.width;
|
||||
const scaleY = visibleModalCanvasRef.current.height / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
// Draw on both canvases
|
||||
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||
const hiddenCtx = modalCanvasRef.current.getContext('2d');
|
||||
|
||||
[visibleCtx, hiddenCtx].forEach(ctx => {
|
||||
if (ctx) {
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}, [isModalDrawing]);
|
||||
|
||||
const stopModalDrawing = useCallback(() => {
|
||||
if (!isModalDrawing) return;
|
||||
setIsModalDrawing(false);
|
||||
|
||||
// Sync the canvases and update signature data (only when drawing stops)
|
||||
if (modalCanvasRef.current) {
|
||||
const dataURL = modalCanvasRef.current.toDataURL('image/png');
|
||||
onSignatureDataChange(dataURL);
|
||||
|
||||
// Also update the small canvas display
|
||||
if (canvasRef.current) {
|
||||
const smallCtx = canvasRef.current.getContext('2d');
|
||||
if (smallCtx) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
smallCtx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||
smallCtx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||
};
|
||||
img.src = dataURL;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isModalDrawing]);
|
||||
|
||||
// Clear canvas functions
|
||||
const clearCanvas = useCallback(() => {
|
||||
if (!canvasRef.current || disabled) return;
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
|
||||
// Also clear the modal canvas if it exists
|
||||
if (modalCanvasRef.current) {
|
||||
const modalCtx = modalCanvasRef.current.getContext('2d');
|
||||
if (modalCtx) {
|
||||
modalCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
|
||||
onSignatureDataChange(null);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const clearModalCanvas = useCallback(() => {
|
||||
// Clear both modal canvases (visible and hidden)
|
||||
if (modalCanvasRef.current) {
|
||||
const hiddenCtx = modalCanvasRef.current.getContext('2d');
|
||||
if (hiddenCtx) {
|
||||
hiddenCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleModalCanvasRef.current) {
|
||||
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||
if (visibleCtx) {
|
||||
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
|
||||
// Also clear the main canvas and signature data
|
||||
if (canvasRef.current) {
|
||||
const mainCtx = canvasRef.current.getContext('2d');
|
||||
if (mainCtx) {
|
||||
mainCtx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||
}
|
||||
}
|
||||
|
||||
onSignatureDataChange(null);
|
||||
}, []);
|
||||
|
||||
const saveModalSignature = useCallback(() => {
|
||||
if (!modalCanvasRef.current) return;
|
||||
|
||||
const dataURL = modalCanvasRef.current.toDataURL('image/png');
|
||||
onSignatureDataChange(dataURL);
|
||||
|
||||
// Copy to small canvas for display
|
||||
if (canvasRef.current) {
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||
ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||
};
|
||||
img.src = dataURL;
|
||||
}
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setIsModalOpen(true);
|
||||
// Copy content to modal canvas after a brief delay
|
||||
setTimeout(() => {
|
||||
if (visibleModalCanvasRef.current && modalCanvasRef.current) {
|
||||
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||
if (visibleCtx) {
|
||||
visibleCtx.strokeStyle = selectedColor;
|
||||
visibleCtx.lineWidth = penSize;
|
||||
visibleCtx.lineCap = 'round';
|
||||
visibleCtx.lineJoin = 'round';
|
||||
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
|
||||
visibleCtx.drawImage(modalCanvasRef.current, 0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}, [selectedColor, penSize]);
|
||||
|
||||
// Initialize canvas settings whenever color or pen size changes
|
||||
React.useEffect(() => {
|
||||
const updateCanvas = (canvas: HTMLCanvasElement | null) => {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.strokeStyle = selectedColor;
|
||||
ctx.lineWidth = penSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
}
|
||||
};
|
||||
|
||||
updateCanvas(canvasRef.current);
|
||||
updateCanvas(modalCanvasRef.current);
|
||||
updateCanvas(visibleModalCanvasRef.current);
|
||||
}, [selectedColor, penSize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<Group gap="lg">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" ta="center">Color</Text>
|
||||
<Group justify="center">
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
onValueChange={onPenSizeChange}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
disabled={disabled}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ paddingTop: '24px' }}>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={openModal}
|
||||
disabled={disabled}
|
||||
>
|
||||
Expand
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'crosshair',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
{additionalButtons}
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={clearCanvas}
|
||||
disabled={disabled}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Hidden canvas for modal synchronization */}
|
||||
<canvas
|
||||
ref={modalCanvasRef}
|
||||
width={modalWidth}
|
||||
height={modalHeight}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Modal for larger signature canvas */}
|
||||
<Modal
|
||||
opened={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="Draw Your Signature"
|
||||
size="xl"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Color and Pen Size picker */}
|
||||
<Paper withBorder p="sm">
|
||||
<Group gap="lg" align="flex-end">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
onValueChange={onPenSizeChange}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<canvas
|
||||
ref={visibleModalCanvasRef}
|
||||
width={modalWidth}
|
||||
height={modalHeight}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: 'crosshair',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
maxWidth: `${modalWidth}px`,
|
||||
height: 'auto',
|
||||
}}
|
||||
onMouseDown={startModalDrawing}
|
||||
onMouseMove={drawModal}
|
||||
onMouseUp={stopModalDrawing}
|
||||
onMouseLeave={stopModalDrawing}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={clearModalCanvas}
|
||||
>
|
||||
Clear Canvas
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveModalSignature}
|
||||
>
|
||||
Save Signature
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingCanvas;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Group, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onPlaceSignature?: () => void;
|
||||
hasSignatureData?: boolean;
|
||||
disabled?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlaceSignature,
|
||||
hasSignatureData = false,
|
||||
disabled = false,
|
||||
showPlaceButton = true,
|
||||
placeButtonText = "Update and Place"
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{/* Undo/Redo Controls */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUndo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.undo', 'Undo')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRedo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.redo', 'Redo')}
|
||||
</Button>
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
flex={1}
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { FileInput, Text, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
onImageChange: (file: File | null) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
onImageChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder,
|
||||
hint
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleImageChange = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
// Validate that it's actually an image file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('Selected file is not an image');
|
||||
return;
|
||||
}
|
||||
|
||||
onImageChange(file);
|
||||
} catch (error) {
|
||||
console.error('Error processing image file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
// Clear image data when no file is selected
|
||||
onImageChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label={label || t('sign.image.label', 'Upload signature image')}
|
||||
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
fontFamily: string;
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
text,
|
||||
onTextChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
fontFamily,
|
||||
onFontFamilyChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
|
||||
// Sync font size input with prop changes
|
||||
useEffect(() => {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}, [fontSize]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times-Roman', label: 'Times' },
|
||||
{ value: 'Courier', label: 'Courier' },
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
];
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48'];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={label || t('sign.text.name', 'Signer Name')}
|
||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label="Font"
|
||||
value={fontFamily}
|
||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||
data={fontOptions}
|
||||
disabled={disabled}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{/* Font Size */}
|
||||
<Combobox
|
||||
onOptionSubmit={(optionValue) => {
|
||||
setFontSizeInput(optionValue);
|
||||
const size = parseInt(optionValue);
|
||||
if (!isNaN(size)) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
fontSizeCombobox.closeDropdown();
|
||||
}}
|
||||
store={fontSizeCombobox}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-72)"
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setFontSizeInput(value);
|
||||
|
||||
// Parse and validate the typed value in real-time
|
||||
const size = parseInt(value);
|
||||
if (!isNaN(size) && size >= 8 && size <= 72) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
|
||||
fontSizeCombobox.openDropdown();
|
||||
fontSizeCombobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => fontSizeCombobox.openDropdown()}
|
||||
onFocus={() => fontSizeCombobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
fontSizeCombobox.closeDropdown();
|
||||
// Clean up invalid values on blur
|
||||
const size = parseInt(fontSizeInput);
|
||||
if (isNaN(size) || size < 8 || size > 72) {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{fontSizeOptions.map((size) => (
|
||||
<Combobox.Option value={size} key={size}>
|
||||
{size}px
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { DrawingCanvas } from '../shared/DrawingCanvas';
|
||||
|
||||
interface DrawingToolProps {
|
||||
onDrawingChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||
onDrawingChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [selectedColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
|
||||
const toolConfig = {
|
||||
enableDrawing: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Drawing"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onDrawingChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={onDrawingChange || (() => {})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { ImageUploader } from '../shared/ImageUploader';
|
||||
|
||||
interface ImageToolProps {
|
||||
onImageChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
onImageChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [, setImageData] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
setImageData(result);
|
||||
onImageChange?.(result);
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageData(null);
|
||||
onImageChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableImageUpload: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Image"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onImageChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<ImageUploader
|
||||
onImageChange={handleImageUpload}
|
||||
disabled={disabled}
|
||||
label="Upload Image"
|
||||
placeholder="Select image file"
|
||||
hint="Upload a PNG, JPG, or other image file to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { TextInputWithFont } from '../shared/TextInputWithFont';
|
||||
|
||||
interface TextToolProps {
|
||||
onTextChange?: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TextTool: React.FC<TextToolProps> = ({
|
||||
onTextChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [fontSize, setFontSize] = useState(16);
|
||||
const [fontFamily, setFontFamily] = useState('Helvetica');
|
||||
|
||||
const handleTextChange = (newText: string) => {
|
||||
setText(newText);
|
||||
onTextChange?.(newText);
|
||||
};
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
if (data) {
|
||||
onTextChange?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableTextInput: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Text"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={handleSignatureDataChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInputWithFont
|
||||
text={text}
|
||||
onTextChange={handleTextChange}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={setFontSize}
|
||||
fontFamily={fontFamily}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
disabled={disabled}
|
||||
label="Text Content"
|
||||
placeholder="Enter text to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -56,6 +56,20 @@
|
||||
border-bottom: 1px solid var(--header-selected-bg);
|
||||
}
|
||||
|
||||
/* Error highlight (transient) */
|
||||
.headerError {
|
||||
background: var(--color-red-200);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--color-red-500);
|
||||
}
|
||||
|
||||
/* Unsupported (but not errored) header appearance */
|
||||
.headerUnsupported {
|
||||
background: var(--unsupported-bar-bg); /* neutral gray */
|
||||
color: #FFFFFF;
|
||||
border-bottom: 1px solid var(--unsupported-bar-border);
|
||||
}
|
||||
|
||||
/* Selected border color in light mode */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: var(--card-selected-border);
|
||||
@@ -80,6 +94,7 @@
|
||||
|
||||
.kebab {
|
||||
justify-self: end;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Menu dropdown */
|
||||
@@ -217,6 +232,22 @@
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Error pill shown when a file failed processing */
|
||||
.errorPill {
|
||||
margin-left: 1.75rem;
|
||||
background: var(--color-red-500);
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
@@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
import { alert } from '../toast';
|
||||
import { downloadBlob } from '../../utils/downloadUtils';
|
||||
|
||||
|
||||
@@ -46,8 +47,16 @@ const FileEditor = ({
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
// Toast helpers
|
||||
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
|
||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||
}, []);
|
||||
const showError = useCallback((message: string) => {
|
||||
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
@@ -82,7 +91,7 @@ const FileEditor = ({
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
@@ -157,18 +166,18 @@ const FileEditor = ({
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
showError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
@@ -206,7 +215,7 @@ const FileEditor = ({
|
||||
} else {
|
||||
// Check if we've hit the selection limit
|
||||
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||
setStatus(`Maximum ${maxAllowed} files can be selected`);
|
||||
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
|
||||
return;
|
||||
}
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
@@ -215,7 +224,7 @@ const FileEditor = ({
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
|
||||
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
@@ -271,8 +280,8 @@ const FileEditor = ({
|
||||
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
|
||||
|
||||
|
||||
|
||||
@@ -297,7 +306,7 @@ const FileEditor = ({
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, setStatus]);
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
@@ -314,10 +323,10 @@ const FileEditor = ({
|
||||
try {
|
||||
// Use FileContext to handle loading stored files
|
||||
// The files are already in FileContext, just need to add them to active files
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
showError('Failed to load some files from storage');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -408,7 +417,7 @@ const FileEditor = ({
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
toolMode={toolMode}
|
||||
@@ -428,31 +437,7 @@ const FileEditor = ({
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
@@ -12,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState } from '../../contexts/file/fileHooks';
|
||||
import { FileId } from '../../types/file';
|
||||
import { formatFileSize } from '../../utils/fileUtils';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
@@ -27,7 +29,7 @@ interface FileEditorThumbnailProps {
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
@@ -40,13 +42,15 @@ const FileEditorThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onSetStatus,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -187,9 +191,20 @@ const FileEditorThumbnail = ({
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
if (!isSupported) return styles.headerUnsupported;
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -199,10 +214,7 @@ const FileEditorThumbnail = ({
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{
|
||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||
}}
|
||||
style={{opacity: isDragging ? 0.9 : 1}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
@@ -210,13 +222,16 @@ const FileEditorThumbnail = ({
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${
|
||||
isSelected ? styles.headerSelected : styles.headerResting
|
||||
}`}
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t('error._value', 'Error')}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
@@ -263,10 +278,10 @@ const FileEditorThumbnail = ({
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus?.(`Unpinned ${file.name}`);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus?.(`Pinned ${file.name}`);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
@@ -278,7 +293,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
|
||||
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
@@ -290,7 +305,7 @@ const FileEditorThumbnail = ({
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
@@ -328,7 +343,10 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
|
||||
@@ -13,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
|
||||
import Viewer from '../viewer/Viewer';
|
||||
import LandingPage from '../shared/LandingPage';
|
||||
import Footer from '../shared/Footer';
|
||||
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@@ -148,9 +149,11 @@ export default function Workbench() {
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
selectedToolKey={selectedToolId}
|
||||
/>
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||
@@ -36,6 +37,9 @@ const PageEditor = ({
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
|
||||
@@ -82,6 +86,12 @@ const PageEditor = ({
|
||||
updateUndoRedoState();
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
// Wrapper for executeCommand to track unsaved changes
|
||||
const executeCommandWithTracking = useCallback((command: any) => {
|
||||
undoManagerRef.current.executeCommand(command);
|
||||
setHasUnsavedChanges(true);
|
||||
}, [setHasUnsavedChanges]);
|
||||
|
||||
// Watch for container size changes to update split line positions
|
||||
useEffect(() => {
|
||||
const container = gridContainerRef.current;
|
||||
@@ -138,17 +148,16 @@ const PageEditor = ({
|
||||
// DOM-first command handlers
|
||||
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
}, []);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}, [executeCommandWithTracking]);
|
||||
|
||||
// Command factory functions for PageThumbnail
|
||||
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}
|
||||
}), []);
|
||||
}), [executeCommandWithTracking]);
|
||||
|
||||
const createDeleteCommand = useCallback((pageIds: string[]) => ({
|
||||
execute: () => {
|
||||
@@ -174,10 +183,10 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}
|
||||
}
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const createSplitCommand = useCallback((position: number) => ({
|
||||
execute: () => {
|
||||
@@ -186,9 +195,9 @@ const PageEditor = ({
|
||||
() => splitPositions,
|
||||
setSplitPositions
|
||||
);
|
||||
undoManagerRef.current.executeCommand(splitCommand);
|
||||
executeCommandWithTracking(splitCommand);
|
||||
}
|
||||
}), [splitPositions]);
|
||||
}), [splitPositions, executeCommandWithTracking]);
|
||||
|
||||
// Command executor for PageThumbnail
|
||||
const executeCommand = useCallback((command: any) => {
|
||||
@@ -232,8 +241,8 @@ const PageEditor = ({
|
||||
() => selectedPageNumbers,
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
|
||||
|
||||
const handleDeletePage = useCallback((pageNumber: number) => {
|
||||
if (!displayDocument) return;
|
||||
@@ -251,8 +260,8 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -298,8 +307,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplitAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -344,8 +353,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreak = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -358,8 +367,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreakAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -372,8 +381,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
|
||||
if (!displayDocument || files.length === 0) return;
|
||||
@@ -416,8 +425,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
// Helper function to collect source files for multi-file export
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
@@ -499,13 +508,14 @@ const PageEditor = ({
|
||||
|
||||
// Step 4: Download the result
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
const onExportAll = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
@@ -552,6 +562,7 @@ const PageEditor = ({
|
||||
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
} else {
|
||||
// Single document - regular export
|
||||
const sourceFiles = getSourceFiles();
|
||||
@@ -570,6 +581,7 @@ const PageEditor = ({
|
||||
);
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
}
|
||||
|
||||
setExportLoading(false);
|
||||
@@ -577,7 +589,7 @@ const PageEditor = ({
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(() => {
|
||||
@@ -779,7 +791,14 @@ const PageEditor = ({
|
||||
)}
|
||||
|
||||
|
||||
<NavigationWarningModal />
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
applyChanges();
|
||||
}}
|
||||
onExportAndContinue={async () => {
|
||||
await onExportAll();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useFileActions } from '../../contexts/file/fileHooks';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface DismissAllErrorsButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Check if there are any files in error state
|
||||
const hasErrors = state.ui.errorFileIds.length > 0;
|
||||
|
||||
// Don't render if there are no errors
|
||||
if (!hasErrors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismissAllErrors = () => {
|
||||
actions.clearAllFileErrors();
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className={className}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<CloseIcon fontSize="small" />}
|
||||
onClick={handleDismissAllErrors}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default DismissAllErrorsButton;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
@@ -11,6 +12,8 @@ const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
@@ -28,7 +31,7 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleApplyAndContinue = async () => {
|
||||
const _handleApplyAndContinue = async () => {
|
||||
if (onApplyAndContinue) {
|
||||
await onApplyAndContinue();
|
||||
}
|
||||
@@ -52,55 +55,59 @@ const NavigationWarningModal = ({
|
||||
<Modal
|
||||
opened={showNavigationWarning}
|
||||
onClose={handleKeepWorking}
|
||||
title="Unsaved Changes"
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
centered
|
||||
size="lg"
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
You have unsaved changes to your PDF. What would you like to do?
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
Keep Working
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
Discard Changes
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
|
||||
{onApplyAndContinue && (
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
color="var(--mantine-color-gray-8)"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
Apply & Continue
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
Export & Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* TODO:: Add this back in when it works */}
|
||||
{/* {onApplyAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
>
|
||||
{t("applyAndContinue", "Apply & Continue")}
|
||||
</Button>
|
||||
)} */}
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
{t("exportAndContinue", "Export & Continue")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationWarningModal;
|
||||
export default NavigationWarningModal;
|
||||
|
||||
@@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ToastProvider } from '../toast';
|
||||
import ToastRenderer from '../toast/ToastRenderer';
|
||||
import { ToastPortalBinder } from '../toast';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
@@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{children}
|
||||
<ToastProvider>
|
||||
<ToastPortalBinder />
|
||||
{children}
|
||||
<ToastRenderer />
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
import { SearchInterface } from '../viewer/SearchInterface';
|
||||
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
|
||||
@@ -39,9 +40,13 @@ export default function RightRail() {
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
// Signature context for checking if signatures have been applied
|
||||
const { signaturesApplied } = useSignature();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
@@ -65,11 +70,16 @@ export default function RightRail() {
|
||||
|
||||
const { totalItems, selectedCount } = getSelectionState();
|
||||
|
||||
// Get export state for viewer mode
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Select all file IDs
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
// Clear any previous error flags when selecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,6 +92,8 @@ export default function RightRail() {
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
setSelectedFiles([]);
|
||||
// Clear any previous error flags when deselecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
if (currentView === 'pageEditor') {
|
||||
@@ -90,8 +102,17 @@ export default function RightRail() {
|
||||
}
|
||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleExportAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
const handleExportAll = useCallback(async () => {
|
||||
if (currentView === 'viewer') {
|
||||
// Check if signatures have been applied
|
||||
if (!signaturesApplied) {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EmbedPDF export functionality for viewer mode
|
||||
viewerContext?.exportActions?.download();
|
||||
} else if (currentView === 'fileEditor') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
@@ -108,7 +129,7 @@ export default function RightRail() {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
}
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
@@ -440,7 +461,9 @@ export default function RightRail() {
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0}
|
||||
disabled={
|
||||
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -19,7 +19,7 @@ const viewOptionStyle = {
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null, isToolSelected: boolean) => {
|
||||
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
|
||||
const viewerOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
@@ -75,7 +75,7 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
|
||||
// Build options array conditionally
|
||||
return [
|
||||
viewerOption,
|
||||
...(isToolSelected ? [] : [pageEditorOption]),
|
||||
pageEditorOption,
|
||||
fileEditorOption,
|
||||
];
|
||||
};
|
||||
@@ -83,19 +83,15 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
|
||||
interface TopControlsProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
selectedToolKey?: string | null;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
selectedToolKey,
|
||||
}: TopControlsProps) => {
|
||||
}: TopControlsProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
return;
|
||||
@@ -122,7 +118,7 @@ const TopControls = ({
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<SegmentedControl
|
||||
data={createViewOptions(currentView, switchingTo, isToolSelected)}
|
||||
data={createViewOptions(currentView, switchingTo)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
|
||||
@@ -33,8 +33,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
const { getHomeNavigation } = useSidebarNavigation();
|
||||
|
||||
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
|
||||
// Special case: multiTool should always show even when sidebars are hidden
|
||||
const indicatorShouldShow = Boolean(
|
||||
selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)
|
||||
selectedToolKey &&
|
||||
((leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)) ||
|
||||
selectedToolKey === 'multiTool')
|
||||
);
|
||||
|
||||
// Local animation and hover state
|
||||
|
||||
@@ -12,7 +12,7 @@ export const isNavButtonActive = (
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
|
||||
): boolean => {
|
||||
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
|
||||
const isActiveByContext =
|
||||
@@ -35,7 +35,7 @@ export const getNavButtonStyle = (
|
||||
isFilesModalOpen: boolean,
|
||||
configModalOpen: boolean,
|
||||
selectedToolKey?: string | null,
|
||||
leftPanelView?: 'toolPicker' | 'toolContent'
|
||||
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
|
||||
) => {
|
||||
const isActive = isNavButtonActive(
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# Toast Component
|
||||
|
||||
A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function.
|
||||
* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware).
|
||||
* 📱 **Expandable Content**: Collapsible toasts with chevron controls and smooth animations.
|
||||
* ⚡ **Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4").
|
||||
* 📊 **Progress Tracking**: Built-in progress bars with completion animations.
|
||||
* 🎛️ **Customizable**: Rich JSX content, buttons with callbacks, custom icons.
|
||||
* 🌙 **Themeable**: Uses CSS variables; supports light/dark mode out of the box.
|
||||
* ♿ **Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support.
|
||||
* 🔄 **Auto-dismiss**: Configurable duration with persistent popup option.
|
||||
* 📍 **Positioning**: Four corner positions with proper stacking.
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
### Default
|
||||
* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`.
|
||||
* **Expandable**: Click chevron to expand/collapse body content (default: collapsed).
|
||||
* **Coalescing**: Identical error toasts merge with count badges.
|
||||
* **Progress**: Progress bars always visible when present, even when collapsed.
|
||||
|
||||
### Error Handling
|
||||
* **Network Errors**: Automatically caught by Axios and fetch interceptors.
|
||||
* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses.
|
||||
* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The toast system is already integrated at the app root. No additional setup required.
|
||||
|
||||
```tsx
|
||||
import { alert, updateToast, dismissToast } from '@/components/toast';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Notifications
|
||||
|
||||
```tsx
|
||||
// Success notification
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'File processed successfully',
|
||||
body: 'Your document has been converted to PDF.'
|
||||
});
|
||||
|
||||
// Error notification
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Processing failed',
|
||||
body: 'Unable to process the selected files.'
|
||||
});
|
||||
|
||||
// Warning notification
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: 'Low disk space',
|
||||
body: 'Consider freeing up some storage space.'
|
||||
});
|
||||
|
||||
// Neutral notification
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Information',
|
||||
body: 'This is a neutral notification.'
|
||||
});
|
||||
```
|
||||
|
||||
### With Custom Content
|
||||
|
||||
```tsx
|
||||
// Rich JSX content with buttons
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'Download complete',
|
||||
body: (
|
||||
<div>
|
||||
<p>File saved to Downloads folder</p>
|
||||
<button onClick={() => openFolder()}>Open folder</button>
|
||||
</div>
|
||||
),
|
||||
buttonText: 'View file',
|
||||
buttonCallback: () => openFile(),
|
||||
isPersistentPopup: true
|
||||
});
|
||||
```
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
```tsx
|
||||
// Show progress
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Processing files...',
|
||||
body: 'Converting your documents',
|
||||
progressBarPercentage: 0
|
||||
});
|
||||
|
||||
// Update progress
|
||||
updateToast(toastId, { progressBarPercentage: 50 });
|
||||
|
||||
// Complete with success
|
||||
updateToast(toastId, {
|
||||
alertType: 'success',
|
||||
title: 'Processing complete',
|
||||
body: 'All files converted successfully',
|
||||
progressBarPercentage: 100
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Positioning
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Connection lost',
|
||||
body: 'Please check your internet connection.',
|
||||
location: 'top-right'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `alert(options: ToastOptions)`
|
||||
|
||||
The primary function for showing toasts.
|
||||
|
||||
```ts
|
||||
interface ToastOptions {
|
||||
alertType?: 'success' | 'error' | 'warning' | 'neutral';
|
||||
title: string;
|
||||
body?: React.ReactNode;
|
||||
buttonText?: string;
|
||||
buttonCallback?: () => void;
|
||||
isPersistentPopup?: boolean;
|
||||
location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
icon?: React.ReactNode;
|
||||
progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent
|
||||
durationMs?: number;
|
||||
id?: string;
|
||||
expandable?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### `updateToast(id: string, options: Partial<ToastOptions>)`
|
||||
|
||||
Update an existing toast.
|
||||
|
||||
```tsx
|
||||
const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 });
|
||||
updateToast(toastId, { progressBarPercentage: 75 });
|
||||
```
|
||||
|
||||
### `dismissToast(id: string)`
|
||||
|
||||
Dismiss a specific toast.
|
||||
|
||||
```tsx
|
||||
dismissToast(toastId);
|
||||
```
|
||||
|
||||
### `dismissAllToasts()`
|
||||
|
||||
Dismiss all visible toasts.
|
||||
|
||||
```tsx
|
||||
dismissAllToasts();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alert Types
|
||||
|
||||
| Type | Color | Icon | Use Case |
|
||||
|------|-------|------|----------|
|
||||
| `success` | Green | ✓ | Successful operations, completions |
|
||||
| `error` | Red | ✗ | Failures, errors, exceptions |
|
||||
| `warning` | Yellow | ⚠ | Warnings, cautions, low resources |
|
||||
| `neutral` | Theme | ℹ | Information, general messages |
|
||||
|
||||
---
|
||||
|
||||
## Positioning
|
||||
|
||||
| Location | Description |
|
||||
|----------|-------------|
|
||||
| `top-left` | Top-left corner |
|
||||
| `top-right` | Top-right corner |
|
||||
| `bottom-left` | Bottom-left corner |
|
||||
| `bottom-right` | Bottom-right corner (default) |
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
* Toasts use `role="status"` for screen readers.
|
||||
* Chevron and close buttons have proper `aria-label` attributes.
|
||||
* Keyboard navigation supported (Escape to dismiss).
|
||||
* Focus management for interactive content.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### File Processing Workflow
|
||||
|
||||
```tsx
|
||||
// Start processing
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Processing files...',
|
||||
body: 'Converting 5 documents',
|
||||
progressBarPercentage: 0,
|
||||
isPersistentPopup: true
|
||||
});
|
||||
|
||||
// Update progress
|
||||
updateToast(toastId, { progressBarPercentage: 30 });
|
||||
updateToast(toastId, { progressBarPercentage: 60 });
|
||||
|
||||
// Complete successfully
|
||||
updateToast(toastId, {
|
||||
alertType: 'success',
|
||||
title: 'Processing complete',
|
||||
body: 'All 5 documents converted successfully',
|
||||
progressBarPercentage: 100,
|
||||
isPersistentPopup: false
|
||||
});
|
||||
```
|
||||
|
||||
### Error with Action
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Upload failed',
|
||||
body: 'File size exceeds the 10MB limit.',
|
||||
buttonText: 'Try again',
|
||||
buttonCallback: () => retryUpload(),
|
||||
isPersistentPopup: true
|
||||
});
|
||||
```
|
||||
|
||||
### Non-expandable Toast
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'Settings saved',
|
||||
body: 'Your preferences have been updated.',
|
||||
expandable: false,
|
||||
durationMs: 3000
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Icon
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: 'New feature available',
|
||||
body: 'Check out the latest updates.',
|
||||
icon: <LocalIcon icon="star" />
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### Network Error Handling
|
||||
|
||||
The toast system automatically catches network errors from Axios and fetch requests:
|
||||
|
||||
```tsx
|
||||
// These automatically show error toasts
|
||||
axios.post('/api/convert', formData);
|
||||
fetch('/api/process', { method: 'POST', body: data });
|
||||
```
|
||||
|
||||
### Manual Error Handling
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await processFiles();
|
||||
alert({ alertType: 'success', title: 'Files processed' });
|
||||
} catch (error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Processing failed',
|
||||
body: error.message
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { ToastApi, ToastInstance, ToastOptions } from './types';
|
||||
|
||||
function normalizeProgress(value: number | undefined): number | undefined {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return undefined;
|
||||
// Accept 0..1 as fraction or 0..100 as percent
|
||||
if (value <= 1) return Math.max(0, Math.min(1, value)) * 100;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `toast_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
type DefaultOpts = Required<Pick<ToastOptions, 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>> &
|
||||
Partial<Omit<ToastOptions, 'id' | 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>>;
|
||||
|
||||
const defaultOptions: DefaultOpts = {
|
||||
alertType: 'neutral',
|
||||
title: '',
|
||||
isPersistentPopup: false,
|
||||
location: 'bottom-right',
|
||||
durationMs: 6000,
|
||||
};
|
||||
|
||||
interface ToastContextShape extends ToastApi {
|
||||
toasts: ToastInstance[];
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextShape | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastInstance[]>([]);
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
|
||||
const scheduleAutoDismiss = useCallback((toast: ToastInstance) => {
|
||||
if (toast.isPersistentPopup) return;
|
||||
window.clearTimeout(timers.current[toast.id]);
|
||||
timers.current[toast.id] = window.setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||
}, toast.durationMs);
|
||||
}, []);
|
||||
|
||||
const show = useCallback<ToastApi['show']>((options) => {
|
||||
const id = options.id || generateId();
|
||||
const hasButton = !!(options.buttonText && options.buttonCallback);
|
||||
const merged: ToastInstance = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
id,
|
||||
progress: normalizeProgress(options.progressBarPercentage),
|
||||
justCompleted: false,
|
||||
expandable: hasButton ? false : (options.expandable !== false),
|
||||
isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)),
|
||||
createdAt: Date.now(),
|
||||
} as ToastInstance;
|
||||
setToasts(prev => {
|
||||
// Coalesce duplicates by alertType + title + body text if no explicit id was provided
|
||||
if (!options.id) {
|
||||
const bodyText = typeof merged.body === 'string' ? merged.body : '';
|
||||
const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText);
|
||||
if (existingIndex !== -1) {
|
||||
const updated = [...prev];
|
||||
const existing = updated[existingIndex];
|
||||
const nextCount = (existing.count ?? 1) + 1;
|
||||
updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() };
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
const next = [...prev.filter(t => t.id !== id), merged];
|
||||
return next;
|
||||
});
|
||||
scheduleAutoDismiss(merged);
|
||||
return id;
|
||||
}, [scheduleAutoDismiss]);
|
||||
|
||||
const update = useCallback<ToastApi['update']>((id, updates) => {
|
||||
setToasts(prev => prev.map(t => {
|
||||
if (t.id !== id) return t;
|
||||
const progress = updates.progressBarPercentage !== undefined
|
||||
? normalizeProgress(updates.progressBarPercentage)
|
||||
: t.progress;
|
||||
|
||||
const next: ToastInstance = {
|
||||
...t,
|
||||
...updates,
|
||||
progress,
|
||||
} as ToastInstance;
|
||||
|
||||
// Detect completion
|
||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||
// On completion: finalize type as success unless explicitly provided otherwise
|
||||
next.justCompleted = false;
|
||||
if (!updates.alertType) {
|
||||
next.alertType = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateProgress = useCallback<ToastApi['updateProgress']>((id, progress) => {
|
||||
update(id, { progressBarPercentage: progress });
|
||||
}, [update]);
|
||||
|
||||
const dismiss = useCallback<ToastApi['dismiss']>((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
}, []);
|
||||
|
||||
const dismissAll = useCallback<ToastApi['dismissAll']>(() => {
|
||||
setToasts([]);
|
||||
Object.values(timers.current).forEach(t => window.clearTimeout(t));
|
||||
timers.current = {};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ToastContextShape>(() => ({
|
||||
toasts,
|
||||
show,
|
||||
update,
|
||||
updateProgress,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
}), [toasts, show, update, updateProgress, dismiss, dismissAll]);
|
||||
|
||||
// Handle expand/collapse toggles from renderer without widening API
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { id: string } | undefined;
|
||||
if (!detail?.id) return;
|
||||
setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t));
|
||||
};
|
||||
window.addEventListener('toast:toggle', handler as EventListener);
|
||||
return () => window.removeEventListener('toast:toggle', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/* Toast Container Styles */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-container--top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-right {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
max-width: 560px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-item--error {
|
||||
background: var(--color-red-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-item--warning {
|
||||
background: var(--color-yellow-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-yellow-400);
|
||||
}
|
||||
|
||||
.toast-item--neutral {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Toast Header Row */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-title-container {
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-expand-button {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast-expand-button--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.toast-progress-container {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
height: 100%;
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.toast-progress-bar--success {
|
||||
background: var(--color-green-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--error {
|
||||
background: var(--color-red-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--warning {
|
||||
background: var(--color-yellow-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--neutral {
|
||||
background: var(--color-gray-500);
|
||||
}
|
||||
|
||||
/* Toast Body */
|
||||
.toast-body {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Toast Action Button */
|
||||
.toast-action-container {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toast-action-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toast-action-button--success {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-action-button--error {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-action-button--warning {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-yellow-400);
|
||||
}
|
||||
|
||||
.toast-action-button--neutral {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { useToast } from './ToastContext';
|
||||
import { ToastInstance, ToastLocation } from './types';
|
||||
import { LocalIcon } from '../shared/LocalIcon';
|
||||
import './ToastRenderer.css';
|
||||
|
||||
const locationToClass: Record<ToastLocation, string> = {
|
||||
'top-left': 'toast-container--top-left',
|
||||
'top-right': 'toast-container--top-right',
|
||||
'bottom-left': 'toast-container--bottom-left',
|
||||
'bottom-right': 'toast-container--bottom-right',
|
||||
};
|
||||
|
||||
function getToastItemClass(t: ToastInstance): string {
|
||||
return `toast-item toast-item--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getProgressBarClass(t: ToastInstance): string {
|
||||
return `toast-progress-bar toast-progress-bar--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getActionButtonClass(t: ToastInstance): string {
|
||||
return `toast-action-button toast-action-button--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getDefaultIconName(t: ToastInstance): string {
|
||||
switch (t.alertType) {
|
||||
case 'success':
|
||||
return 'check-circle-rounded';
|
||||
case 'error':
|
||||
return 'close-rounded';
|
||||
case 'warning':
|
||||
return 'warning-rounded';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'info-rounded';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ToastRenderer() {
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>((acc, t) => {
|
||||
const key = t.location;
|
||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
||||
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(grouped) as ToastLocation[]).map((loc) => (
|
||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||
{grouped[loc].map(t => {
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={getToastItemClass(t)}
|
||||
>
|
||||
{/* Top row: Icon + Title + Controls */}
|
||||
<div className="toast-header">
|
||||
{/* Icon */}
|
||||
<div className="toast-icon">
|
||||
{t.icon ?? (
|
||||
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + count badge */}
|
||||
<div className="toast-title-container">
|
||||
<span>{t.title}</span>
|
||||
{typeof t.count === 'number' && t.count > 1 && (
|
||||
<span className="toast-count-badge">{t.count}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="toast-controls">
|
||||
{t.expandable && (
|
||||
<button
|
||||
aria-label="Toggle details"
|
||||
onClick={() => {
|
||||
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
|
||||
window.dispatchEvent(evt);
|
||||
}}
|
||||
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="toast-button"
|
||||
>
|
||||
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar - always show when present */}
|
||||
{typeof t.progress === 'number' && (
|
||||
<div className="toast-progress-container">
|
||||
<div
|
||||
className={getProgressBarClass(t)}
|
||||
style={{ width: `${t.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body content - only show when expanded */}
|
||||
{(t.isExpanded || !t.expandable) && (
|
||||
<div className="toast-body">
|
||||
{t.body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button - always show when present, positioned below body */}
|
||||
{t.buttonText && t.buttonCallback && (
|
||||
<div className="toast-action-container">
|
||||
<button
|
||||
onClick={t.buttonCallback}
|
||||
className={getActionButtonClass(t)}
|
||||
>
|
||||
{t.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ToastOptions } from './types';
|
||||
import { useToast, ToastProvider } from './ToastContext';
|
||||
import ToastRenderer from './ToastRenderer';
|
||||
|
||||
export { useToast, ToastProvider, ToastRenderer };
|
||||
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!_api) _api = createImperativeApi();
|
||||
|
||||
// Hook helper to wire context API back to singleton
|
||||
export function ToastPortalBinder() {
|
||||
const ctx = useToast();
|
||||
// Provide API once mounted
|
||||
_api!.provide(ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function alert(options: ToastOptions) {
|
||||
if (_api?.get()) {
|
||||
return _api.get()!.show(options);
|
||||
}
|
||||
// Queue until provider mounts
|
||||
let id = '';
|
||||
_api?.onReady((api) => { id = api.show(options); });
|
||||
return id;
|
||||
}
|
||||
|
||||
export function updateToast(id: string, options: Partial<ToastOptions>) {
|
||||
_api?.get()?.update(id, options);
|
||||
}
|
||||
|
||||
export function updateToastProgress(id: string, progress: number) {
|
||||
_api?.get()?.updateProgress(id, progress);
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
_api?.get()?.dismiss(id);
|
||||
}
|
||||
|
||||
export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||
|
||||
export interface ToastOptions {
|
||||
alertType?: ToastAlertType;
|
||||
title: string;
|
||||
body?: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonCallback?: () => void;
|
||||
isPersistentPopup?: boolean;
|
||||
location?: ToastLocation;
|
||||
icon?: ReactNode;
|
||||
/** number 0-1 as fraction or 0-100 as percent */
|
||||
progressBarPercentage?: number;
|
||||
/** milliseconds to auto-close if not persistent */
|
||||
durationMs?: number;
|
||||
/** optional id to control/update later */
|
||||
id?: string;
|
||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
export interface ToastInstance extends Omit<ToastOptions, 'id' | 'progressBarPercentage'> {
|
||||
id: string;
|
||||
alertType: ToastAlertType;
|
||||
isPersistentPopup: boolean;
|
||||
location: ToastLocation;
|
||||
durationMs: number;
|
||||
expandable: boolean;
|
||||
isExpanded: boolean;
|
||||
/** Number of coalesced duplicates */
|
||||
count?: number;
|
||||
/** internal progress normalized 0..100 */
|
||||
progress?: number;
|
||||
/** if progress completed, briefly show check icon */
|
||||
justCompleted: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
show: (options: ToastOptions) => string;
|
||||
update: (id: string, options: Partial<ToastOptions>) => void;
|
||||
updateProgress: (id: string, progress: number) => void;
|
||||
dismiss: (id: string) => void;
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,22 @@ import NoToolsFound from './shared/NoToolsFound';
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
|
||||
interface SearchResultsProps {
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
onSelect: (id: string) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }) => {
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect, searchQuery }) => {
|
||||
const { t } = useTranslation();
|
||||
const { searchGroups } = useToolSections(filteredTools);
|
||||
const { searchGroups } = useToolSections(filteredTools, searchQuery);
|
||||
|
||||
// Create a map of matched text for quick lookup
|
||||
const matchedTextMap = new Map<string, string>();
|
||||
if (filteredTools && Array.isArray(filteredTools)) {
|
||||
filteredTools.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) matchedTextMap.set(id, matchedText);
|
||||
});
|
||||
}
|
||||
|
||||
if (searchGroups.length === 0) {
|
||||
return <NoToolsFound />;
|
||||
@@ -28,15 +37,27 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
|
||||
<Box key={group.subcategoryId} w="100%">
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
|
||||
<Stack gap="xs">
|
||||
{group.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
{group.tools.map(({ id, tool }) => {
|
||||
const matchedText = matchedTextMap.get(id);
|
||||
// Check if the match was from synonyms and show the actual synonym that matched
|
||||
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
);
|
||||
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
matchedSynonym={matchedSynonym}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -7,6 +7,7 @@ import ToolSearch from './toolPicker/ToolSearch';
|
||||
import { useSidebarContext } from "../../contexts/SidebarContext";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ScrollArea } from '@mantine/core';
|
||||
import { ToolId } from '../../types/toolId';
|
||||
|
||||
// No props needed - component uses context
|
||||
|
||||
@@ -71,7 +72,8 @@ export default function ToolPanel() {
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleToolSelect}
|
||||
onSelect={(id) => handleToolSelect(id as ToolId)}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
@@ -79,7 +81,7 @@ export default function ToolPanel() {
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={handleToolSelect}
|
||||
onSelect={(id) => handleToolSelect(id as ToolId)}
|
||||
filteredTools={filteredTools}
|
||||
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { renderToolButtons } from "./shared/renderToolButtons";
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,13 @@ export default function ToolSelector({
|
||||
return registry;
|
||||
}, [baseFilteredTools]);
|
||||
|
||||
// Transform filteredTools to the expected format for useToolSections
|
||||
const transformedFilteredTools = useMemo(() => {
|
||||
return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
|
||||
}, [filteredTools]);
|
||||
|
||||
// Use the same tool sections logic as the main ToolPicker
|
||||
const { sections, searchGroups } = useToolSections(filteredTools);
|
||||
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
|
||||
|
||||
// Determine what to display: search results or organized sections
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Select, Checkbox } from '@mantine/core';
|
||||
import { ExtractImagesParameters } from '../../../hooks/tools/extractImages/useExtractImagesParameters';
|
||||
|
||||
interface ExtractImagesSettingsProps {
|
||||
parameters: ExtractImagesParameters;
|
||||
onParameterChange: <K extends keyof ExtractImagesParameters>(key: K, value: ExtractImagesParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ExtractImagesSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: ExtractImagesSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label={t('extractImages.selectText', 'Output Format')}
|
||||
value={parameters.format}
|
||||
onChange={(value) => {
|
||||
const allowedFormats = ['png', 'jpg', 'gif'] as const;
|
||||
const format = allowedFormats.includes(value as any) ? (value as typeof allowedFormats[number]) : 'png';
|
||||
onParameterChange('format', format);
|
||||
}}
|
||||
data={[
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpg', label: 'JPG' },
|
||||
{ value: 'gif', label: 'GIF' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t('extractImages.allowDuplicates', 'Allow Duplicate Images')}
|
||||
checked={parameters.allowDuplicates}
|
||||
onChange={(event) => onParameterChange('allowDuplicates', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtractImagesSettings;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Divider, Select, Stack, Switch } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PageLayoutParameters } from '../../../hooks/tools/pageLayout/usePageLayoutParameters';
|
||||
import { getPagesPerSheetOptions } from './constants';
|
||||
|
||||
export default function PageLayoutSettings({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled,
|
||||
}: {
|
||||
parameters: PageLayoutParameters;
|
||||
onParameterChange: <K extends keyof PageLayoutParameters>(
|
||||
key: K,
|
||||
value: PageLayoutParameters[K]
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = getPagesPerSheetOptions(t);
|
||||
const selected = options.find((o) => o.value === parameters.pagesPerSheet) || options[0];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t('pageLayout.pagesPerSheet', 'Pages per sheet:')}
|
||||
data={options.map(o => ({ value: String(o.value), label: o.label }))}
|
||||
value={String(parameters.pagesPerSheet)}
|
||||
onChange={(v) => onParameterChange('pagesPerSheet', Number(v))}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--information-text-bg)',
|
||||
color: 'var(--information-text-color)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
marginTop: '4px',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{selected.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Switch
|
||||
checked={parameters.addBorder}
|
||||
onChange={(e) => onParameterChange('addBorder', e.currentTarget.checked)}
|
||||
label={t('pageLayout.addBorder', 'Add Borders')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
export type PagesPerSheetOption = {
|
||||
value: number;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const getPagesPerSheetOptions = (t: TFunction): PagesPerSheetOption[] => [
|
||||
{
|
||||
value: 2,
|
||||
label: '2',
|
||||
description: t('pageLayout.desc.2', 'Place 2 pages side-by-side on a single sheet.')
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '3',
|
||||
description: t('pageLayout.desc.3', 'Place 3 pages on a single sheet in a single row.')
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: '4',
|
||||
description: t('pageLayout.desc.4', 'Place 4 pages on a single sheet (2 × 2 grid).')
|
||||
},
|
||||
{
|
||||
value: 9,
|
||||
label: '9',
|
||||
description: t('pageLayout.desc.9', 'Place 9 pages on a single sheet (3 × 3 grid).')
|
||||
},
|
||||
{
|
||||
value: 16,
|
||||
label: '16',
|
||||
description: t('pageLayout.desc.16', 'Place 16 pages on a single sheet (4 × 4 grid).')
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Alert } from '@mantine/core';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
|
||||
const RemoveAnnotationsSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info-rounded" width="1.2rem" height="1.2rem" />}
|
||||
title={t('removeAnnotations.info.title', 'About Remove Annotations')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('removeAnnotations.info.description',
|
||||
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoveAnnotationsSettings;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { Divider, Select, Stack, TextInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReorganizePagesParameters } from '../../../hooks/tools/reorganizePages/useReorganizePagesParameters';
|
||||
import { getReorganizePagesModeData } from './constants';
|
||||
|
||||
export default function ReorganizePagesSettings({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled,
|
||||
}: {
|
||||
parameters: ReorganizePagesParameters;
|
||||
onParameterChange: <K extends keyof ReorganizePagesParameters>(
|
||||
key: K,
|
||||
value: ReorganizePagesParameters[K]
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const modeData = getReorganizePagesModeData(t);
|
||||
|
||||
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
|
||||
const selectedMode = modeData.find(mode => mode.value === parameters.customMode) || modeData[0];
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t('pdfOrganiser.mode._value', 'Organization mode')}
|
||||
data={modeData}
|
||||
value={parameters.customMode}
|
||||
onChange={(v) => onParameterChange('customMode', v ?? '')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{selectedMode && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--information-text-bg)',
|
||||
color: 'var(--information-text-color)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
marginTop: '4px',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{selectedMode.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requiresOrder && (
|
||||
<>
|
||||
<Divider/>
|
||||
<TextInput
|
||||
label={t('pageOrderPrompt', 'Page order / ranges')}
|
||||
placeholder={t('pdfOrganiser.placeholder', 'e.g. 1,3,2,4-6')}
|
||||
value={parameters.pageNumbers}
|
||||
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
export const getReorganizePagesModeData = (t: TFunction) => [
|
||||
{
|
||||
value: '',
|
||||
label: t('pdfOrganiser.mode.1', 'Custom Page Order'),
|
||||
description: t('pdfOrganiser.mode.desc.CUSTOM', 'Use a custom sequence of page numbers or expressions to define a new order.')
|
||||
},
|
||||
{
|
||||
value: 'REVERSE_ORDER',
|
||||
label: t('pdfOrganiser.mode.2', 'Reverse Order'),
|
||||
description: t('pdfOrganiser.mode.desc.REVERSE_ORDER', 'Flip the document so the last page becomes first and so on.')
|
||||
},
|
||||
{
|
||||
value: 'DUPLEX_SORT',
|
||||
label: t('pdfOrganiser.mode.3', 'Duplex Sort'),
|
||||
description: t('pdfOrganiser.mode.desc.DUPLEX_SORT', 'Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).')
|
||||
},
|
||||
{
|
||||
value: 'BOOKLET_SORT',
|
||||
label: t('pdfOrganiser.mode.4', 'Booklet Sort'),
|
||||
description: t('pdfOrganiser.mode.desc.BOOKLET_SORT', 'Arrange pages for booklet printing (last, first, second, second last, …).')
|
||||
},
|
||||
{
|
||||
value: 'SIDE_STITCH_BOOKLET_SORT',
|
||||
label: t('pdfOrganiser.mode.5', 'Side Stitch Booklet Sort'),
|
||||
description: t('pdfOrganiser.mode.desc.SIDE_STITCH_BOOKLET_SORT', 'Arrange pages for side‑stitch booklet printing (optimized for binding on the side).')
|
||||
},
|
||||
{
|
||||
value: 'ODD_EVEN_SPLIT',
|
||||
label: t('pdfOrganiser.mode.6', 'Odd-Even Split'),
|
||||
description: t('pdfOrganiser.mode.desc.ODD_EVEN_SPLIT', 'Split the document into two outputs: all odd pages and all even pages.')
|
||||
},
|
||||
{
|
||||
value: 'ODD_EVEN_MERGE',
|
||||
label: t('pdfOrganiser.mode.10', 'Odd-Even Merge'),
|
||||
description: t('pdfOrganiser.mode.desc.ODD_EVEN_MERGE', 'Merge two PDFs by alternating pages: odd from the first, even from the second.')
|
||||
},
|
||||
{
|
||||
value: 'DUPLICATE',
|
||||
label: t('pdfOrganiser.mode.11', 'Duplicate all pages'),
|
||||
description: t('pdfOrganiser.mode.desc.DUPLICATE', 'Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).')
|
||||
},
|
||||
{
|
||||
value: 'REMOVE_FIRST',
|
||||
label: t('pdfOrganiser.mode.7', 'Remove First'),
|
||||
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST', 'Remove the first page from the document.')
|
||||
},
|
||||
{
|
||||
value: 'REMOVE_LAST',
|
||||
label: t('pdfOrganiser.mode.8', 'Remove Last'),
|
||||
description: t('pdfOrganiser.mode.desc.REMOVE_LAST', 'Remove the last page from the document.')
|
||||
},
|
||||
{
|
||||
value: 'REMOVE_FIRST_AND_LAST',
|
||||
label: t('pdfOrganiser.mode.9', 'Remove First and Last'),
|
||||
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST_AND_LAST', 'Remove both the first and last pages from the document.')
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { Stack, Text, Select, ColorInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ReplaceColorParameters } from "../../../hooks/tools/replaceColor/useReplaceColorParameters";
|
||||
|
||||
interface ReplaceColorSettingsProps {
|
||||
parameters: ReplaceColorParameters;
|
||||
onParameterChange: <K extends keyof ReplaceColorParameters>(key: K, value: ReplaceColorParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ReplaceColorSettings = ({ parameters, onParameterChange, disabled = false }: ReplaceColorSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const replaceAndInvertOptions = [
|
||||
{
|
||||
value: 'HIGH_CONTRAST_COLOR',
|
||||
label: t('replaceColor.options.highContrast', 'High contrast')
|
||||
},
|
||||
{
|
||||
value: 'FULL_INVERSION',
|
||||
label: t('replaceColor.options.invertAll', 'Invert all colours')
|
||||
},
|
||||
{
|
||||
value: 'CUSTOM_COLOR',
|
||||
label: t('replaceColor.options.custom', 'Custom')
|
||||
}
|
||||
];
|
||||
|
||||
const highContrastOptions = [
|
||||
{
|
||||
value: 'WHITE_TEXT_ON_BLACK',
|
||||
label: t('replace-color.selectText.6', 'White text on black background')
|
||||
},
|
||||
{
|
||||
value: 'BLACK_TEXT_ON_WHITE',
|
||||
label: t('replace-color.selectText.7', 'Black text on white background')
|
||||
},
|
||||
{
|
||||
value: 'YELLOW_TEXT_ON_BLACK',
|
||||
label: t('replace-color.selectText.8', 'Yellow text on black background')
|
||||
},
|
||||
{
|
||||
value: 'GREEN_TEXT_ON_BLACK',
|
||||
label: t('replace-color.selectText.9', 'Green text on black background')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replaceColor.labels.colourOperation', 'Colour operation')}
|
||||
</Text>
|
||||
<Select
|
||||
value={parameters.replaceAndInvertOption}
|
||||
onChange={(value) => value && onParameterChange('replaceAndInvertOption', value as ReplaceColorParameters['replaceAndInvertOption'])}
|
||||
data={replaceAndInvertOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR' && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replace-color.selectText.5', 'High contrast color options')}
|
||||
</Text>
|
||||
<Select
|
||||
value={parameters.highContrastColorCombination}
|
||||
onChange={(value) => value && onParameterChange('highContrastColorCombination', value as ReplaceColorParameters['highContrastColorCombination'])}
|
||||
data={highContrastOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{parameters.replaceAndInvertOption === 'CUSTOM_COLOR' && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replace-color.selectText.10', 'Choose text Color')}
|
||||
</Text>
|
||||
<ColorInput
|
||||
value={parameters.textColor}
|
||||
onChange={(value) => onParameterChange('textColor', value)}
|
||||
format="hex"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('replace-color.selectText.11', 'Choose background Color')}
|
||||
</Text>
|
||||
<ColorInput
|
||||
value={parameters.backGroundColor}
|
||||
onChange={(value) => onParameterChange('backGroundColor', value)}
|
||||
format="hex"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplaceColorSettings;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Stack } from '@mantine/core';
|
||||
import { ScannerImageSplitParameters } from '../../../hooks/tools/scannerImageSplit/useScannerImageSplitParameters';
|
||||
|
||||
interface ScannerImageSplitSettingsProps {
|
||||
parameters: ScannerImageSplitParameters;
|
||||
onParameterChange: <K extends keyof ScannerImageSplitParameters>(key: K, value: ScannerImageSplitParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ScannerImageSplitSettings: React.FC<ScannerImageSplitSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.1', 'Angle Threshold:')}
|
||||
description={t('ScannerImageSplit.selectText.2', 'Sets the minimum absolute angle required for the image to be rotated (default: 10).')}
|
||||
value={parameters.angle_threshold}
|
||||
onChange={(value) => onParameterChange('angle_threshold', Number(value) || 10)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.3', 'Tolerance:')}
|
||||
description={t('ScannerImageSplit.selectText.4', 'Determines the range of colour variation around the estimated background colour (default: 30).')}
|
||||
value={parameters.tolerance}
|
||||
onChange={(value) => onParameterChange('tolerance', Number(value) || 30)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.5', 'Minimum Area:')}
|
||||
description={t('ScannerImageSplit.selectText.6', 'Sets the minimum area threshold for a photo (default: 10000).')}
|
||||
value={parameters.min_area}
|
||||
onChange={(value) => onParameterChange('min_area', Number(value) || 10000)}
|
||||
min={0}
|
||||
step={100}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.7', 'Minimum Contour Area:')}
|
||||
description={t('ScannerImageSplit.selectText.8', 'Sets the minimum contour area threshold for a photo.')}
|
||||
value={parameters.min_contour_area}
|
||||
onChange={(value) => onParameterChange('min_contour_area', Number(value) || 500)}
|
||||
min={0}
|
||||
step={10}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.9', 'Border Size:')}
|
||||
description={t('ScannerImageSplit.selectText.10', 'Sets the size of the border added and removed to prevent white borders in the output (default: 1).')}
|
||||
value={parameters.border_size}
|
||||
onChange={(value) => onParameterChange('border_size', Number(value) || 1)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScannerImageSplitSettings;
|
||||
@@ -13,23 +13,39 @@ export const renderToolButtons = (
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
showSubcategoryHeader: boolean = true,
|
||||
disableNavigation: boolean = false
|
||||
) => (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<div>
|
||||
{subcategory.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
disableNavigation={disableNavigation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
disableNavigation: boolean = false,
|
||||
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
|
||||
) => {
|
||||
// Create a map of matched text for quick lookup
|
||||
const matchedTextMap = new Map<string, string>();
|
||||
if (searchResults) {
|
||||
searchResults.forEach(({ item: [id], matchedText }) => {
|
||||
if (matchedText) matchedTextMap.set(id, matchedText);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={subcategory.subcategoryId} w="100%">
|
||||
{showSubcategoryHeader && (
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
|
||||
)}
|
||||
<div>
|
||||
{subcategory.tools.map(({ id, tool }) => {
|
||||
const matchedSynonym = matchedTextMap.get(id);
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
disableNavigation={disableNavigation}
|
||||
matchedSynonym={matchedSynonym}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { TextInput, Combobox, useCombobox } from '@mantine/core';
|
||||
|
||||
interface PenSizeSelectorProps {
|
||||
value: number;
|
||||
inputValue: string;
|
||||
onValueChange: (size: number) => void;
|
||||
onInputChange: (input: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
style?: React.CSSProperties;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
const PenSizeSelector = ({
|
||||
value,
|
||||
inputValue,
|
||||
onValueChange,
|
||||
onInputChange,
|
||||
disabled = false,
|
||||
placeholder = "Type or select pen size (1-200)",
|
||||
style,
|
||||
size
|
||||
}: PenSizeSelectorProps) => {
|
||||
const combobox = useCombobox();
|
||||
|
||||
const penSizeOptions = ['1', '2', '3', '4', '5', '8', '10', '12', '15', '20'];
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
onOptionSubmit={(optionValue) => {
|
||||
const penSize = parseInt(optionValue);
|
||||
if (!isNaN(penSize)) {
|
||||
onValueChange(penSize);
|
||||
onInputChange(optionValue);
|
||||
}
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
const inputVal = event.currentTarget.value;
|
||||
onInputChange(inputVal);
|
||||
|
||||
const penSize = parseInt(inputVal);
|
||||
if (!isNaN(penSize) && penSize >= 1 && penSize <= 200) {
|
||||
onValueChange(penSize);
|
||||
}
|
||||
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
const penSize = parseInt(inputValue);
|
||||
if (isNaN(penSize) || penSize < 1 || penSize > 200) {
|
||||
onInputChange(value.toString());
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{penSizeOptions.map((sizeOption) => (
|
||||
<Combobox.Option value={sizeOption} key={sizeOption}>
|
||||
{sizeOption}px
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default PenSizeSelector;
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Button, Text, Alert, Tabs } from '@mantine/core';
|
||||
import { SignParameters } from "../../../hooks/tools/sign/useSignParameters";
|
||||
import { SuggestedToolsSection } from "../shared/SuggestedToolsSection";
|
||||
|
||||
// Import the new reusable components
|
||||
import { DrawingCanvas } from "../../annotation/shared/DrawingCanvas";
|
||||
import { DrawingControls } from "../../annotation/shared/DrawingControls";
|
||||
import { ImageUploader } from "../../annotation/shared/ImageUploader";
|
||||
import { TextInputWithFont } from "../../annotation/shared/TextInputWithFont";
|
||||
import { ColorPicker } from "../../annotation/shared/ColorPicker";
|
||||
|
||||
interface SignSettingsProps {
|
||||
parameters: SignParameters;
|
||||
onParameterChange: <K extends keyof SignParameters>(key: K, value: SignParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
onActivateDrawMode?: () => void;
|
||||
onActivateSignaturePlacement?: () => void;
|
||||
onDeactivateSignature?: () => void;
|
||||
onUpdateDrawSettings?: (color: string, size: number) => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onSave?: () => void;
|
||||
}
|
||||
|
||||
const SignSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
onActivateSignaturePlacement,
|
||||
onDeactivateSignature,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSave
|
||||
}: SignSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State for drawing
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
|
||||
// State for different signature types
|
||||
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
|
||||
const [imageSignatureData, setImageSignatureData] = useState<string | null>(null);
|
||||
|
||||
// Handle image upload
|
||||
const handleImageChange = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Clear any existing canvas signatures when uploading image
|
||||
setCanvasSignatureData(null);
|
||||
setImageSignatureData(result);
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageSignatureData(null);
|
||||
if (onDeactivateSignature) {
|
||||
onDeactivateSignature();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle signature data changes
|
||||
const handleCanvasSignatureChange = (data: string | null) => {
|
||||
setCanvasSignatureData(prev => {
|
||||
if (prev === data) return prev; // Prevent unnecessary updates
|
||||
return data;
|
||||
});
|
||||
if (data) {
|
||||
// Clear image data when canvas is used
|
||||
setImageSignatureData(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle signature mode deactivation when switching types
|
||||
useEffect(() => {
|
||||
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
|
||||
onDeactivateSignature();
|
||||
}
|
||||
}, [parameters.signatureType]);
|
||||
|
||||
// Handle text signature activation
|
||||
useEffect(() => {
|
||||
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
|
||||
if (onActivateSignaturePlacement) {
|
||||
setTimeout(() => {
|
||||
onActivateSignaturePlacement();
|
||||
}, 100);
|
||||
}
|
||||
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
|
||||
if (onDeactivateSignature) {
|
||||
onDeactivateSignature();
|
||||
}
|
||||
}
|
||||
}, [parameters.signatureType, parameters.signerName, onActivateSignaturePlacement, onDeactivateSignature]);
|
||||
|
||||
// Handle signature data updates
|
||||
useEffect(() => {
|
||||
let newSignatureData: string | undefined = undefined;
|
||||
|
||||
if (parameters.signatureType === 'image' && imageSignatureData) {
|
||||
newSignatureData = imageSignatureData;
|
||||
} else if (parameters.signatureType === 'canvas' && canvasSignatureData) {
|
||||
newSignatureData = canvasSignatureData;
|
||||
}
|
||||
|
||||
// Only update if the signature data has actually changed
|
||||
if (parameters.signatureData !== newSignatureData) {
|
||||
onParameterChange('signatureData', newSignatureData);
|
||||
}
|
||||
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData]);
|
||||
|
||||
// Handle image signature activation - activate when image data syncs with parameters
|
||||
useEffect(() => {
|
||||
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
|
||||
setTimeout(() => {
|
||||
onActivateSignaturePlacement();
|
||||
}, 100);
|
||||
}
|
||||
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
|
||||
|
||||
// Draw settings are no longer needed since draw mode is removed
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Signature Type Selection */}
|
||||
<Tabs
|
||||
value={parameters.signatureType}
|
||||
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
|
||||
>
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="canvas" style={{ fontSize: '0.8rem' }}>
|
||||
{t('sign.type.canvas', 'Canvas')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="image" style={{ fontSize: '0.8rem' }}>
|
||||
{t('sign.type.image', 'Image')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="text" style={{ fontSize: '0.8rem' }}>
|
||||
{t('sign.type.text', 'Text')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{/* Drawing Controls */}
|
||||
<DrawingControls
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
onPlaceSignature={() => {
|
||||
if (onActivateSignaturePlacement) {
|
||||
onActivateSignaturePlacement();
|
||||
}
|
||||
}}
|
||||
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
|
||||
disabled={disabled}
|
||||
showPlaceButton={false}
|
||||
placeButtonText="Update and Place"
|
||||
/>
|
||||
|
||||
{/* Signature Creation based on type */}
|
||||
{parameters.signatureType === 'canvas' && (
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => setIsColorPickerOpen(true)}
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={handleCanvasSignatureChange}
|
||||
disabled={disabled}
|
||||
additionalButtons={
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (onActivateSignaturePlacement) {
|
||||
onActivateSignaturePlacement();
|
||||
}
|
||||
}}
|
||||
color="blue"
|
||||
variant="filled"
|
||||
disabled={disabled || !canvasSignatureData}
|
||||
>
|
||||
Update and Place
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parameters.signatureType === 'image' && (
|
||||
<ImageUploader
|
||||
onImageChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parameters.signatureType === 'text' && (
|
||||
<TextInputWithFont
|
||||
text={parameters.signerName || ''}
|
||||
onTextChange={(text) => onParameterChange('signerName', text)}
|
||||
fontSize={parameters.fontSize || 16}
|
||||
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
|
||||
fontFamily={parameters.fontFamily || 'Helvetica'}
|
||||
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||
<Text size="sm">
|
||||
{parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'}
|
||||
{parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'}
|
||||
{parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
/>
|
||||
|
||||
{/* Apply Signatures Button */}
|
||||
{onSave && (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
color="blue"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
>
|
||||
{t('sign.applySignatures', 'Apply Signatures')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Suggested Tools Section */}
|
||||
<SuggestedToolsSection />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignSettings;
|
||||
@@ -13,10 +13,12 @@ interface ToolButtonProps {
|
||||
onSelect: (id: string) => void;
|
||||
rounded?: boolean;
|
||||
disableNavigation?: boolean;
|
||||
matchedSynonym?: string;
|
||||
}
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => {
|
||||
const isUnavailable = !tool.component && !tool.link;
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
@@ -40,13 +42,27 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
const buttonContent = (
|
||||
<>
|
||||
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
{matchedSynonym && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
opacity: isUnavailable ? 0.25 : 1,
|
||||
marginTop: '1px',
|
||||
overflow: 'visible',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{matchedSynonym}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -66,7 +82,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
@@ -84,7 +103,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
@@ -99,7 +121,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
aria-disabled={isUnavailable}
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
|
||||
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' }, label: { overflow: 'visible' } }}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import LocalIcon from '../../shared/LocalIcon';
|
||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import { TextInput } from "../../shared/TextInput";
|
||||
import "./ToolPicker.css";
|
||||
import { rankByFuzzy, idToWords } from "../../../utils/fuzzySearch";
|
||||
|
||||
interface ToolSearchProps {
|
||||
value: string;
|
||||
@@ -38,15 +39,14 @@ const ToolSearch = ({
|
||||
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!value.trim()) return [];
|
||||
return Object.entries(toolRegistry)
|
||||
.filter(([id, tool]) => {
|
||||
if (mode === "dropdown" && id === selectedToolKey) return false;
|
||||
return (
|
||||
tool.name.toLowerCase().includes(value.toLowerCase()) || tool.description.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map(([id, tool]) => ({ id, tool }));
|
||||
const entries = Object.entries(toolRegistry).filter(([id]) => !(mode === "dropdown" && id === selectedToolKey));
|
||||
const ranked = rankByFuzzy(entries, value, [
|
||||
([key]) => idToWords(key),
|
||||
([, v]) => v.name,
|
||||
([, v]) => v.description,
|
||||
([, v]) => v.synonyms?.join(' ') || '',
|
||||
]).slice(0, 6);
|
||||
return ranked.map(({ item: [id, tool] }) => ({ id, tool }));
|
||||
}, [value, toolRegistry, mode, selectedToolKey]);
|
||||
|
||||
const handleSearchChange = (searchValue: string) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useReplaceColorTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("replaceColor.tooltip.header.title", "Replace & Invert Colour Settings Overview")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("replaceColor.tooltip.description.title", "Description"),
|
||||
description: t("replaceColor.tooltip.description.text", "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes.")
|
||||
},
|
||||
{
|
||||
title: t("replaceColor.tooltip.highContrast.title", "High Contrast"),
|
||||
description: t("replaceColor.tooltip.highContrast.text", "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance."),
|
||||
bullets: [
|
||||
t("replaceColor.tooltip.highContrast.bullet1", "White text on black background - Classic dark mode"),
|
||||
t("replaceColor.tooltip.highContrast.bullet2", "Black text on white background - Standard high contrast"),
|
||||
t("replaceColor.tooltip.highContrast.bullet3", "Yellow text on black background - High visibility option"),
|
||||
t("replaceColor.tooltip.highContrast.bullet4", "Green text on black background - Alternative high contrast")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("replaceColor.tooltip.invertAll.title", "Invert All Colours"),
|
||||
description: t("replaceColor.tooltip.invertAll.text", "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions.")
|
||||
},
|
||||
{
|
||||
title: t("replaceColor.tooltip.custom.title", "Custom Colours"),
|
||||
description: t("replaceColor.tooltip.custom.text", "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements."),
|
||||
bullets: [
|
||||
t("replaceColor.tooltip.custom.bullet1", "Text colour - Choose the colour for text elements"),
|
||||
t("replaceColor.tooltip.custom.bullet2", "Background colour - Set the background colour for the document")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useScannerImageSplitTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('scannerImageSplit.tooltip.title', 'Photo Splitter')
|
||||
},
|
||||
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.tooltip.whenToUse', 'When to use'),
|
||||
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')
|
||||
]
|
||||
},
|
||||
{
|
||||
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.'
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -9,6 +9,8 @@ import { useViewer } from "../../contexts/ViewerContext";
|
||||
import { LocalEmbedPDF } from './LocalEmbedPDF';
|
||||
import { PdfViewerToolbar } from './PdfViewerToolbar';
|
||||
import { ThumbnailSidebar } from './ThumbnailSidebar';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
@@ -33,6 +35,12 @@ const EmbedPdfViewerContent = ({
|
||||
const zoomState = getZoomState();
|
||||
const spreadState = getSpreadState();
|
||||
|
||||
// Check if we're in signature mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignatureMode = selectedTool === 'sign';
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors } = useFileState();
|
||||
@@ -178,6 +186,13 @@ const EmbedPdfViewerContent = ({
|
||||
<LocalEmbedPDF
|
||||
file={effectiveFile.file}
|
||||
url={effectiveFile.url}
|
||||
enableSignature={isSignatureMode}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
// Handle signature added - for debugging, enable console logs as needed
|
||||
// Future: Handle signature completion
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useExportCapability } from '@embedpdf/plugin-export/react';
|
||||
import { useViewer } from '../../contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and provides export functionality
|
||||
*/
|
||||
export function ExportAPIBridge() {
|
||||
const { provides: exportApi } = useExportCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
useEffect(() => {
|
||||
if (exportApi) {
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('export', {
|
||||
state: {
|
||||
canExport: true,
|
||||
},
|
||||
api: exportApi
|
||||
});
|
||||
}
|
||||
}, [exportApi, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import { uuidV4 } from '@embedpdf/models';
|
||||
|
||||
export interface HistoryAPI {
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
}
|
||||
|
||||
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
|
||||
const { provides: historyApi } = useHistoryCapability();
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { getImageData, storeImageData } = useSignature();
|
||||
|
||||
// Monitor annotation events to detect when annotations are restored
|
||||
useEffect(() => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
const handleAnnotationEvent = (event: any) => {
|
||||
const annotation = event.annotation;
|
||||
|
||||
// Store image data for all STAMP annotations immediately when created or modified
|
||||
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||
const storedImageData = getImageData(annotation.id);
|
||||
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
||||
storeImageData(annotation.id, annotation.imageSrc);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle annotation restoration after undo operations
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||
if (annotation && annotation.type === 13 && annotation.id) {
|
||||
getImageData(annotation.id);
|
||||
|
||||
// Delay the check to allow the annotation to be fully created
|
||||
setTimeout(() => {
|
||||
const currentStoredData = getImageData(annotation.id);
|
||||
// Check if the annotation lacks image data but we have it stored
|
||||
if (currentStoredData && (!annotation.imageSrc || annotation.imageSrc !== currentStoredData)) {
|
||||
|
||||
// Generate new ID to avoid React key conflicts
|
||||
const newId = uuidV4();
|
||||
|
||||
// Recreation with stored image data
|
||||
const restoredData = {
|
||||
type: annotation.type,
|
||||
rect: annotation.rect,
|
||||
author: annotation.author || 'Digital Signature',
|
||||
subject: annotation.subject || 'Digital Signature',
|
||||
pageIndex: event.pageIndex,
|
||||
id: newId,
|
||||
created: annotation.created || new Date(),
|
||||
imageSrc: currentStoredData
|
||||
};
|
||||
|
||||
// Update stored data to use new ID
|
||||
storeImageData(newId, currentStoredData);
|
||||
|
||||
// Replace the annotation with one that has proper image data
|
||||
try {
|
||||
annotationApi.deleteAnnotation(event.pageIndex, annotation.id);
|
||||
// Small delay to ensure deletion completes
|
||||
setTimeout(() => {
|
||||
annotationApi.createAnnotation(event.pageIndex, restoredData);
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error('HistoryAPI: Failed to restore annotation:', error);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener
|
||||
annotationApi.onAnnotationEvent(handleAnnotationEvent);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
// Note: EmbedPDF doesn't provide a way to remove event listeners
|
||||
// This is a limitation of the current API
|
||||
};
|
||||
}, [annotationApi, getImageData, storeImageData]);
|
||||
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
undo: () => {
|
||||
if (historyApi) {
|
||||
historyApi.undo();
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
if (historyApi) {
|
||||
historyApi.redo();
|
||||
}
|
||||
},
|
||||
|
||||
canUndo: () => {
|
||||
return historyApi ? historyApi.canUndo() : false;
|
||||
},
|
||||
|
||||
canRedo: () => {
|
||||
return historyApi ? historyApi.canRedo() : false;
|
||||
},
|
||||
}), [historyApi]);
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
});
|
||||
|
||||
HistoryAPIBridge.displayName = 'HistoryAPIBridge';
|
||||
@@ -17,7 +17,13 @@ import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||
import { Rotation } from '@embedpdf/models';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
import { CustomSearchLayer } from './CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from './ZoomAPIBridge';
|
||||
import ToolLoadingFallback from '../tools/ToolLoadingFallback';
|
||||
@@ -29,14 +35,22 @@ import { SpreadAPIBridge } from './SpreadAPIBridge';
|
||||
import { SearchAPIBridge } from './SearchAPIBridge';
|
||||
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
|
||||
import { RotateAPIBridge } from './RotateAPIBridge';
|
||||
import { SignatureAPIBridge, SignatureAPI } from './SignatureAPIBridge';
|
||||
import { HistoryAPIBridge, HistoryAPI } from './HistoryAPIBridge';
|
||||
import { ExportAPIBridge } from './ExportAPIBridge';
|
||||
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
enableSignature?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
|
||||
// Convert File to URL if needed
|
||||
useEffect(() => {
|
||||
@@ -78,6 +92,17 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
// Register selection plugin (depends on InteractionManager)
|
||||
createPluginRegistration(SelectionPluginPackage),
|
||||
|
||||
// Register history plugin for undo/redo (recommended for annotations)
|
||||
...(enableSignature ? [createPluginRegistration(HistoryPluginPackage)] : []),
|
||||
|
||||
// Register annotation plugin (depends on InteractionManager, Selection, History)
|
||||
...(enableSignature ? [createPluginRegistration(AnnotationPluginPackage, {
|
||||
annotationAuthor: 'Digital Signature',
|
||||
autoCommit: true,
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
})] : []),
|
||||
|
||||
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||
@@ -112,6 +137,11 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
createPluginRegistration(RotatePluginPackage, {
|
||||
defaultRotation: Rotation.Degree0, // Start with no rotation
|
||||
}),
|
||||
|
||||
// Register export plugin for downloading PDFs
|
||||
createPluginRegistration(ExportPluginPackage, {
|
||||
defaultFileName: 'document.pdf',
|
||||
}),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
|
||||
@@ -161,7 +191,72 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
minHeight: 0,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<EmbedPDF engine={engine} plugins={plugins}>
|
||||
<EmbedPDF
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={enableSignature ? async (registry) => {
|
||||
const annotationPlugin = registry.getPlugin('annotation');
|
||||
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||
|
||||
const annotationApi = annotationPlugin.provides();
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Add custom signature stamp tool for image signatures
|
||||
annotationApi.addTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
// Image will be set dynamically when signature is created
|
||||
},
|
||||
});
|
||||
|
||||
// Add custom ink signature tool for drawn signatures
|
||||
annotationApi.addTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#000000',
|
||||
opacity: 1.0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for annotation events to track annotations and notify parent
|
||||
annotationApi.onAnnotationEvent((event: any) => {
|
||||
if (event.type === 'create' && event.committed) {
|
||||
// Add to annotations list
|
||||
setAnnotations(prev => [...prev, {
|
||||
id: event.annotation.id,
|
||||
pageIndex: event.pageIndex,
|
||||
rect: event.annotation.rect
|
||||
}]);
|
||||
|
||||
|
||||
// Notify parent if callback provided
|
||||
if (onSignatureAdded) {
|
||||
onSignatureAdded(event.annotation);
|
||||
}
|
||||
} else if (event.type === 'delete' && event.committed) {
|
||||
// Remove from annotations list
|
||||
setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
|
||||
} else if (event.type === 'loaded') {
|
||||
// Handle initial load of annotations
|
||||
const loadedAnnotations = event.annotations || [];
|
||||
setAnnotations(loadedAnnotations.map((ann: any) => ({
|
||||
id: ann.id,
|
||||
pageIndex: ann.pageIndex || 0,
|
||||
rect: ann.rect
|
||||
})));
|
||||
}
|
||||
});
|
||||
} : undefined}
|
||||
>
|
||||
<ZoomAPIBridge />
|
||||
<ScrollAPIBridge />
|
||||
<SelectionAPIBridge />
|
||||
@@ -170,6 +265,9 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
<SearchAPIBridge />
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
{enableSignature && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableSignature && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
@@ -213,6 +311,17 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
|
||||
{/* Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
{/* Annotation layer for signatures (only when enabled) */}
|
||||
{enableSignature && (
|
||||
<AnnotationLayer
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
rotation={rotation || 0}
|
||||
selectionOutlineColor="#007ACC"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||
|
||||
// Import the essential plugins
|
||||
import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react';
|
||||
import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
|
||||
import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
|
||||
import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
|
||||
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
|
||||
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
|
||||
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
|
||||
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
|
||||
import { PanPluginPackage } from '@embedpdf/plugin-pan/react';
|
||||
import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { Rotation } from '@embedpdf/models';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||
|
||||
import { CustomSearchLayer } from './CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from './ZoomAPIBridge';
|
||||
import ToolLoadingFallback from '../tools/ToolLoadingFallback';
|
||||
import { Center, Stack, Text } from '@mantine/core';
|
||||
import { ScrollAPIBridge } from './ScrollAPIBridge';
|
||||
import { SelectionAPIBridge } from './SelectionAPIBridge';
|
||||
import { PanAPIBridge } from './PanAPIBridge';
|
||||
import { SpreadAPIBridge } from './SpreadAPIBridge';
|
||||
import { SearchAPIBridge } from './SearchAPIBridge';
|
||||
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
|
||||
import { RotateAPIBridge } from './RotateAPIBridge';
|
||||
|
||||
interface LocalEmbedPDFWithAnnotationsProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
onAnnotationChange?: (annotations: any[]) => void;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDFWithAnnotations({
|
||||
file,
|
||||
url,
|
||||
onAnnotationChange
|
||||
}: LocalEmbedPDFWithAnnotationsProps) {
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
|
||||
// Convert File to URL if needed
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPdfUrl(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else if (url) {
|
||||
setPdfUrl(url);
|
||||
}
|
||||
}, [file, url]);
|
||||
|
||||
// Create plugins configuration with annotation support
|
||||
const plugins = useMemo(() => {
|
||||
if (!pdfUrl) return [];
|
||||
|
||||
return [
|
||||
createPluginRegistration(LoaderPluginPackage, {
|
||||
loadingOptions: {
|
||||
type: 'url',
|
||||
pdfFile: {
|
||||
id: 'stirling-pdf-signing-viewer',
|
||||
url: pdfUrl,
|
||||
},
|
||||
},
|
||||
}),
|
||||
createPluginRegistration(ViewportPluginPackage, {
|
||||
viewportGap: 10,
|
||||
}),
|
||||
createPluginRegistration(ScrollPluginPackage, {
|
||||
strategy: ScrollStrategy.Vertical,
|
||||
initialPage: 0,
|
||||
}),
|
||||
createPluginRegistration(RenderPluginPackage),
|
||||
|
||||
// Register interaction manager (required for annotations)
|
||||
createPluginRegistration(InteractionManagerPluginPackage),
|
||||
|
||||
// Register selection plugin (depends on InteractionManager)
|
||||
createPluginRegistration(SelectionPluginPackage),
|
||||
|
||||
// Register history plugin for undo/redo (recommended for annotations)
|
||||
createPluginRegistration(HistoryPluginPackage),
|
||||
|
||||
// Register annotation plugin (depends on InteractionManager, Selection, History)
|
||||
createPluginRegistration(AnnotationPluginPackage, {
|
||||
annotationAuthor: 'Digital Signature',
|
||||
autoCommit: true,
|
||||
deactivateToolAfterCreate: false,
|
||||
selectAfterCreate: true,
|
||||
}),
|
||||
|
||||
// Register pan plugin
|
||||
createPluginRegistration(PanPluginPackage, {
|
||||
defaultMode: 'mobile',
|
||||
}),
|
||||
|
||||
// Register zoom plugin
|
||||
createPluginRegistration(ZoomPluginPackage, {
|
||||
defaultZoomLevel: 1.4,
|
||||
minZoom: 0.2,
|
||||
maxZoom: 3.0,
|
||||
}),
|
||||
|
||||
// Register tiling plugin
|
||||
createPluginRegistration(TilingPluginPackage, {
|
||||
tileSize: 768,
|
||||
overlapPx: 5,
|
||||
extraRings: 1,
|
||||
}),
|
||||
|
||||
// Register spread plugin
|
||||
createPluginRegistration(SpreadPluginPackage, {
|
||||
defaultSpreadMode: SpreadMode.None,
|
||||
}),
|
||||
|
||||
// Register search plugin
|
||||
createPluginRegistration(SearchPluginPackage),
|
||||
|
||||
// Register thumbnail plugin
|
||||
createPluginRegistration(ThumbnailPluginPackage),
|
||||
|
||||
// Register rotate plugin
|
||||
createPluginRegistration(RotatePluginPackage, {
|
||||
defaultRotation: Rotation.Degree0,
|
||||
}),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
|
||||
// Initialize the engine
|
||||
const { engine, isLoading, error } = usePdfiumEngine();
|
||||
|
||||
// Early return if no file or URL provided
|
||||
if (!file && !url) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<div style={{ fontSize: '24px' }}>📄</div>
|
||||
<Text c="dimmed" size="sm">
|
||||
No PDF provided
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !engine || !pdfUrl) {
|
||||
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<div style={{ fontSize: '24px' }}>❌</div>
|
||||
<Text c="red" size="sm" style={{ textAlign: 'center' }}>
|
||||
Error loading PDF engine: {error.message}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<EmbedPDF
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={async (registry) => {
|
||||
const annotationPlugin = registry.getPlugin('annotation');
|
||||
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||
|
||||
const annotationApi = annotationPlugin.provides();
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Add custom signature stamp tool
|
||||
annotationApi.addTool({
|
||||
id: 'signatureStamp',
|
||||
name: 'Digital Signature',
|
||||
interaction: { exclusive: false, cursor: 'copy' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
// Will be set dynamically when user creates signature
|
||||
},
|
||||
});
|
||||
|
||||
// Add custom ink signature tool
|
||||
annotationApi.addTool({
|
||||
id: 'signatureInk',
|
||||
name: 'Signature Draw',
|
||||
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||
matchScore: () => 0,
|
||||
defaults: {
|
||||
type: PdfAnnotationSubtype.INK,
|
||||
color: '#000000',
|
||||
opacity: 1.0,
|
||||
borderWidth: 2,
|
||||
},
|
||||
});
|
||||
|
||||
// Listen for annotation events to notify parent
|
||||
if (onAnnotationChange) {
|
||||
annotationApi.onAnnotationEvent((event: any) => {
|
||||
if (event.committed) {
|
||||
// Get all annotations and notify parent
|
||||
// This is a simplified approach - in reality you'd need to get all annotations
|
||||
onAnnotationChange([event.annotation]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ZoomAPIBridge />
|
||||
<ScrollAPIBridge />
|
||||
<SelectionAPIBridge />
|
||||
<PanAPIBridge />
|
||||
<SpreadAPIBridge />
|
||||
<SearchAPIBridge />
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
contain: 'strict',
|
||||
}}
|
||||
>
|
||||
<Scroller
|
||||
renderPage={({ width, height, pageIndex, scale, rotation }: {
|
||||
width: number;
|
||||
height: number;
|
||||
pageIndex: number;
|
||||
scale: number;
|
||||
rotation?: number;
|
||||
}) => (
|
||||
<Rotate pageSize={{ width, height }}>
|
||||
<PagePointerProvider {...{
|
||||
pageWidth: width,
|
||||
pageHeight: height,
|
||||
pageIndex,
|
||||
scale,
|
||||
rotation: rotation || 0
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* High-resolution tile layer */}
|
||||
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Search highlight layer */}
|
||||
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Annotation layer for signatures */}
|
||||
<AnnotationLayer
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
rotation={rotation || 0}
|
||||
selectionOutlineColor="#007ACC"
|
||||
/>
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
)}
|
||||
/>
|
||||
</Viewport>
|
||||
</GlobalPointerProvider>
|
||||
</EmbedPDF>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
|
||||
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
|
||||
export interface SignatureAPI {
|
||||
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
|
||||
addTextSignature: (text: string, x: number, y: number, pageIndex: number) => void;
|
||||
activateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
deleteAnnotation: (annotationId: string, pageIndex: number) => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
deactivateTools: () => void;
|
||||
applySignatureFromParameters: (params: SignParameters) => void;
|
||||
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
||||
}
|
||||
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations - only when in signature placement mode
|
||||
useEffect(() => {
|
||||
if (!annotationApi || !isPlacementMode) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||
|
||||
if (selectedAnnotation) {
|
||||
const annotation = selectedAnnotation as any;
|
||||
const pageIndex = annotation.object?.pageIndex || 0;
|
||||
const id = annotation.object?.id;
|
||||
|
||||
// For STAMP annotations, ensure image data is preserved before deletion
|
||||
if (annotation.object?.type === 13 && id) {
|
||||
// Get current annotation data to ensure we have latest image data stored
|
||||
const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex });
|
||||
if (pageAnnotationsTask) {
|
||||
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||
const currentAnn = pageAnnotations?.find((ann: any) => ann.id === id);
|
||||
if (currentAnn && currentAnn.imageSrc) {
|
||||
// Ensure the image data is stored in our persistent store
|
||||
storeImageData(id, currentAnn.imageSrc);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use EmbedPDF's native deletion which should integrate with history
|
||||
if ((annotationApi as any).deleteSelected) {
|
||||
(annotationApi as any).deleteSelected();
|
||||
} else {
|
||||
// Fallback to direct deletion - less ideal for history
|
||||
if (id) {
|
||||
annotationApi.deleteAnnotation(pageIndex, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [annotationApi, storeImageData, isPlacementMode]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Create image stamp annotation with proper image data
|
||||
|
||||
const annotationId = uuidV4();
|
||||
|
||||
// Store image data in our persistent store
|
||||
storeImageData(annotationId, signatureData);
|
||||
|
||||
annotationApi.createAnnotation(pageIndex, {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
rect: {
|
||||
origin: { x, y },
|
||||
size: { width, height }
|
||||
},
|
||||
author: 'Digital Signature',
|
||||
subject: 'Digital Signature',
|
||||
pageIndex: pageIndex,
|
||||
id: annotationId,
|
||||
created: new Date(),
|
||||
// Store image data in multiple places to ensure history captures it
|
||||
imageSrc: signatureData,
|
||||
contents: signatureData, // Some annotation systems use contents
|
||||
data: signatureData, // Try data field
|
||||
imageData: signatureData, // Try imageData field
|
||||
appearance: signatureData // Try appearance field
|
||||
});
|
||||
},
|
||||
|
||||
addTextSignature: (text: string, x: number, y: number, pageIndex: number) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Create text annotation for signature
|
||||
annotationApi.createAnnotation(pageIndex, {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
rect: {
|
||||
origin: { x, y },
|
||||
size: { width: 200, height: 50 }
|
||||
},
|
||||
contents: text,
|
||||
author: 'Digital Signature',
|
||||
fontSize: 16,
|
||||
fontColor: '#000000',
|
||||
fontFamily: PdfStandardFont.Helvetica,
|
||||
textAlign: PdfTextAlignment.Left,
|
||||
verticalAlign: PdfVerticalAlignment.Top,
|
||||
opacity: 1,
|
||||
pageIndex: pageIndex,
|
||||
id: uuidV4(),
|
||||
created: new Date(),
|
||||
customData: {
|
||||
signatureText: text,
|
||||
signatureType: 'text'
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
activateDrawMode: () => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Activate the built-in ink tool for drawing
|
||||
annotationApi.setActiveTool('ink');
|
||||
|
||||
// Set default ink tool properties (black color, 2px width)
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
if (activeTool && activeTool.id === 'ink') {
|
||||
annotationApi.setToolDefaults('ink', {
|
||||
color: '#000000',
|
||||
thickness: 2,
|
||||
lineWidth: 2,
|
||||
strokeWidth: 2,
|
||||
width: 2
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
activateSignaturePlacementMode: () => {
|
||||
if (!annotationApi || !signatureConfig) return;
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
// Try different tool names for text annotations
|
||||
const textToolNames = ['freetext', 'text', 'textbox', 'annotation-text'];
|
||||
let activatedTool = null;
|
||||
|
||||
for (const toolName of textToolNames) {
|
||||
annotationApi.setActiveTool(toolName);
|
||||
const tool = annotationApi.getActiveTool();
|
||||
|
||||
if (tool && tool.id === toolName) {
|
||||
activatedTool = tool;
|
||||
annotationApi.setToolDefaults(toolName, {
|
||||
contents: signatureConfig.signerName,
|
||||
fontSize: signatureConfig.fontSize || 16,
|
||||
fontFamily: signatureConfig.fontFamily === 'Times-Roman' ? PdfStandardFont.Times_Roman :
|
||||
signatureConfig.fontFamily === 'Courier' ? PdfStandardFont.Courier :
|
||||
PdfStandardFont.Helvetica,
|
||||
fontColor: '#000000',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activatedTool) {
|
||||
// Fallback: create a simple text image as stamp
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const fontSize = signatureConfig.fontSize || 16;
|
||||
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||
|
||||
canvas.width = Math.max(200, signatureConfig.signerName.length * fontSize * 0.6);
|
||||
canvas.height = fontSize + 20;
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
|
||||
const dataURL = canvas.toDataURL();
|
||||
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const stampTool = annotationApi.getActiveTool();
|
||||
if (stampTool && stampTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: dataURL,
|
||||
subject: `Text Signature - ${signatureConfig.signerName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (signatureConfig.signatureData) {
|
||||
// Use stamp tool for image/canvas signatures
|
||||
annotationApi.setActiveTool('stamp');
|
||||
const activeTool = annotationApi.getActiveTool();
|
||||
if (activeTool && activeTool.id === 'stamp') {
|
||||
annotationApi.setToolDefaults('stamp', {
|
||||
imageSrc: signatureConfig.signatureData,
|
||||
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error activating signature tool:', error);
|
||||
}
|
||||
},
|
||||
|
||||
updateDrawSettings: (color: string, size: number) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Always update ink tool defaults - use multiple property names for compatibility
|
||||
annotationApi.setToolDefaults('ink', {
|
||||
color: color,
|
||||
thickness: size,
|
||||
lineWidth: size,
|
||||
strokeWidth: size,
|
||||
width: size
|
||||
});
|
||||
|
||||
// Force reactivate ink tool to ensure new settings take effect
|
||||
annotationApi.setActiveTool(null); // Deactivate first
|
||||
setTimeout(() => {
|
||||
annotationApi.setActiveTool('ink'); // Reactivate with new settings
|
||||
}, 50);
|
||||
},
|
||||
|
||||
activateDeleteMode: () => {
|
||||
if (!annotationApi) return;
|
||||
// Activate selection tool to allow selecting and deleting annotations
|
||||
// Users can click annotations to select them, then press Delete key or right-click to delete
|
||||
annotationApi.setActiveTool('select');
|
||||
},
|
||||
|
||||
deleteAnnotation: (annotationId: string, pageIndex: number) => {
|
||||
if (!annotationApi) return;
|
||||
|
||||
// Before deleting, try to preserve image data for potential undo
|
||||
const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex });
|
||||
if (pageAnnotationsTask) {
|
||||
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||
const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId);
|
||||
if (annotation && annotation.type === 13 && annotation.imageSrc) {
|
||||
// Store image data before deletion
|
||||
storeImageData(annotationId, annotation.imageSrc);
|
||||
}
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
// Delete specific annotation by ID
|
||||
annotationApi.deleteAnnotation(pageIndex, annotationId);
|
||||
},
|
||||
|
||||
deactivateTools: () => {
|
||||
if (!annotationApi) return;
|
||||
annotationApi.setActiveTool(null);
|
||||
},
|
||||
|
||||
applySignatureFromParameters: (params: SignParameters) => {
|
||||
if (!annotationApi || !params.signaturePosition) return;
|
||||
|
||||
const { x, y, width, height, page } = params.signaturePosition;
|
||||
|
||||
switch (params.signatureType) {
|
||||
case 'image':
|
||||
if (params.signatureData) {
|
||||
const annotationId = uuidV4();
|
||||
|
||||
// Store image data in our persistent store
|
||||
storeImageData(annotationId, params.signatureData);
|
||||
|
||||
annotationApi.createAnnotation(page, {
|
||||
type: PdfAnnotationSubtype.STAMP,
|
||||
rect: {
|
||||
origin: { x, y },
|
||||
size: { width, height }
|
||||
},
|
||||
author: 'Digital Signature',
|
||||
subject: `Digital Signature - ${params.reason || 'Document signing'}`,
|
||||
pageIndex: page,
|
||||
id: annotationId,
|
||||
created: new Date(),
|
||||
// Store image data in multiple places to ensure history captures it
|
||||
imageSrc: params.signatureData,
|
||||
contents: params.signatureData, // Some annotation systems use contents
|
||||
data: params.signatureData, // Try data field
|
||||
imageData: params.signatureData, // Try imageData field
|
||||
appearance: params.signatureData // Try appearance field
|
||||
});
|
||||
|
||||
// Switch to select mode after placing signature so it can be easily deleted
|
||||
setTimeout(() => {
|
||||
annotationApi.setActiveTool('select');
|
||||
}, 100);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (params.signerName) {
|
||||
annotationApi.createAnnotation(page, {
|
||||
type: PdfAnnotationSubtype.FREETEXT,
|
||||
rect: {
|
||||
origin: { x, y },
|
||||
size: { width, height }
|
||||
},
|
||||
contents: params.signerName,
|
||||
author: 'Digital Signature',
|
||||
fontSize: 16,
|
||||
fontColor: '#000000',
|
||||
fontFamily: PdfStandardFont.Helvetica,
|
||||
textAlign: PdfTextAlignment.Left,
|
||||
verticalAlign: PdfVerticalAlignment.Top,
|
||||
opacity: 1,
|
||||
pageIndex: page,
|
||||
id: uuidV4(),
|
||||
created: new Date(),
|
||||
customData: {
|
||||
signatureText: params.signerName,
|
||||
signatureType: 'text'
|
||||
}
|
||||
});
|
||||
|
||||
// Switch to select mode after placing signature so it can be easily deleted
|
||||
setTimeout(() => {
|
||||
annotationApi.setActiveTool('select');
|
||||
}, 100);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'draw':
|
||||
// For draw mode, we activate the tool and let user draw
|
||||
annotationApi.setActiveTool('ink');
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
getPageAnnotations: async (pageIndex: number): Promise<any[]> => {
|
||||
if (!annotationApi || !annotationApi.getPageAnnotations) {
|
||||
console.warn('getPageAnnotations not available');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const pageAnnotationsTask = annotationApi.getPageAnnotations({ pageIndex });
|
||||
if (pageAnnotationsTask && pageAnnotationsTask.toPromise) {
|
||||
const annotations = await pageAnnotationsTask.toPromise();
|
||||
return annotations || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error getting annotations for page ${pageIndex}:`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}), [annotationApi, signatureConfig]);
|
||||
|
||||
|
||||
return null; // This is a bridge component with no UI
|
||||
});
|
||||
|
||||
SignatureAPIBridge.displayName = 'SignatureAPIBridge';
|
||||
@@ -25,7 +25,7 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSide
|
||||
});
|
||||
setThumbnails({});
|
||||
}
|
||||
}, [visible, thumbnails]);
|
||||
}, [visible]); // Remove thumbnails from dependency to prevent infinite loop
|
||||
|
||||
// Generate thumbnails when sidebar becomes visible
|
||||
useEffect(() => {
|
||||
|
||||
@@ -109,16 +109,34 @@ export const NavigationProvider: React.FC<{
|
||||
|
||||
const actions: NavigationContextActions = {
|
||||
setWorkbench: useCallback((workbench: WorkbenchType) => {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
}, []),
|
||||
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
|
||||
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
|
||||
const performWorkbenchChange = () => {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
};
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
} else {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
}
|
||||
}, [state.workbench, state.hasUnsavedChanges]),
|
||||
|
||||
setSelectedTool: useCallback((toolId: ToolId | null) => {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
|
||||
}, []),
|
||||
|
||||
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
||||
}, []),
|
||||
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
|
||||
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
|
||||
const performWorkbenchChange = () => {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
||||
};
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
} else {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
||||
}
|
||||
}, [state.workbench, state.hasUnsavedChanges]),
|
||||
|
||||
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
|
||||
import { SignParameters } from '../hooks/tools/sign/useSignParameters';
|
||||
import { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
|
||||
import { HistoryAPI } from '../components/viewer/HistoryAPIBridge';
|
||||
|
||||
// Signature state interface
|
||||
interface SignatureState {
|
||||
// Current signature configuration from the tool
|
||||
signatureConfig: SignParameters | null;
|
||||
// Whether we're in signature placement mode
|
||||
isPlacementMode: boolean;
|
||||
// Whether signatures have been applied (allows export)
|
||||
signaturesApplied: boolean;
|
||||
}
|
||||
|
||||
// Signature actions interface
|
||||
interface SignatureActions {
|
||||
setSignatureConfig: (config: SignParameters | null) => void;
|
||||
setPlacementMode: (enabled: boolean) => void;
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
setSignaturesApplied: (applied: boolean) => void;
|
||||
}
|
||||
|
||||
// Combined context interface
|
||||
interface SignatureContextValue extends SignatureState, SignatureActions {
|
||||
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||
historyApiRef: React.RefObject<HistoryAPI | null>;
|
||||
}
|
||||
|
||||
// Create context
|
||||
const SignatureContext = createContext<SignatureContextValue | undefined>(undefined);
|
||||
|
||||
// Initial state
|
||||
const initialState: SignatureState = {
|
||||
signatureConfig: null,
|
||||
isPlacementMode: false,
|
||||
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||
};
|
||||
|
||||
// Provider component
|
||||
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [state, setState] = useState<SignatureState>(initialState);
|
||||
const signatureApiRef = useRef<SignatureAPI>(null);
|
||||
const historyApiRef = useRef<HistoryAPI>(null);
|
||||
const imageDataStore = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Actions
|
||||
const setSignatureConfig = useCallback((config: SignParameters | null) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
signatureConfig: config,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setPlacementMode = useCallback((enabled: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isPlacementMode: enabled,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const activateDrawMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
setPlacementMode(true);
|
||||
// Mark signatures as not applied when entering draw mode
|
||||
setState(prev => ({ ...prev, signaturesApplied: false }));
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const deactivateDrawMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
setPlacementMode(false);
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const activateSignaturePlacementMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.activateSignaturePlacementMode();
|
||||
setPlacementMode(true);
|
||||
// Mark signatures as not applied when placing new signatures
|
||||
setState(prev => ({ ...prev, signaturesApplied: false }));
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const activateDeleteMode = useCallback(() => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.activateDeleteMode();
|
||||
setPlacementMode(true);
|
||||
}
|
||||
}, [setPlacementMode]);
|
||||
|
||||
const updateDrawSettings = useCallback((color: string, size: number) => {
|
||||
if (signatureApiRef.current) {
|
||||
signatureApiRef.current.updateDrawSettings(color, size);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyApiRef.current) {
|
||||
historyApiRef.current.undo();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyApiRef.current) {
|
||||
historyApiRef.current.redo();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const storeImageData = useCallback((id: string, data: string) => {
|
||||
imageDataStore.current.set(id, data);
|
||||
}, []);
|
||||
|
||||
const getImageData = useCallback((id: string) => {
|
||||
return imageDataStore.current.get(id);
|
||||
}, []);
|
||||
|
||||
const setSignaturesApplied = useCallback((applied: boolean) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
signaturesApplied: applied,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// No auto-activation - all modes use manual buttons
|
||||
|
||||
const contextValue: SignatureContextValue = {
|
||||
...state,
|
||||
signatureApiRef,
|
||||
historyApiRef,
|
||||
setSignatureConfig,
|
||||
setPlacementMode,
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
};
|
||||
|
||||
return (
|
||||
<SignatureContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SignatureContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to use signature context
|
||||
export const useSignature = (): SignatureContextValue => {
|
||||
const context = useContext(SignatureContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSignature must be used within a SignatureProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Hook for components that need to check if signature mode is active
|
||||
export const useSignatureMode = () => {
|
||||
const context = useContext(SignatureContext);
|
||||
return {
|
||||
isSignatureModeActive: context?.isPlacementMode || false,
|
||||
hasSignatureConfig: context?.signatureConfig !== null,
|
||||
};
|
||||
};
|
||||
@@ -11,12 +11,13 @@ import { useNavigationActions, useNavigationState } from './NavigationContext';
|
||||
import { ToolId, isValidToolId } from '../types/toolId';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
import { getDefaultWorkbench } from '../types/workbench';
|
||||
import { filterToolRegistryByQuery } from '../utils/toolSearch';
|
||||
|
||||
// State interface
|
||||
interface ToolWorkflowState {
|
||||
// UI State
|
||||
sidebarsVisible: boolean;
|
||||
leftPanelView: 'toolPicker' | 'toolContent';
|
||||
leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
|
||||
readerMode: boolean;
|
||||
|
||||
// File/Preview State
|
||||
@@ -30,7 +31,7 @@ interface ToolWorkflowState {
|
||||
// Actions
|
||||
type ToolWorkflowAction =
|
||||
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
|
||||
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' }
|
||||
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' }
|
||||
| { type: 'SET_READER_MODE'; payload: boolean }
|
||||
| { type: 'SET_PREVIEW_FILE'; payload: File | null }
|
||||
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
|
||||
@@ -79,7 +80,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
|
||||
setLeftPanelView: (view: 'toolPicker' | 'toolContent' | 'hidden') => void;
|
||||
setReaderMode: (mode: boolean) => void;
|
||||
setPreviewFile: (file: File | null) => void;
|
||||
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
|
||||
@@ -95,12 +96,12 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
resetTool: (toolId: string) => void;
|
||||
|
||||
// Workflow Actions (compound actions)
|
||||
handleToolSelect: (toolId: string) => void;
|
||||
handleToolSelect: (toolId: ToolId) => void;
|
||||
handleBackToTools: () => void;
|
||||
handleReaderToggle: () => void;
|
||||
|
||||
// Computed values
|
||||
filteredTools: [string, ToolRegistryEntry][]; // Filtered by search
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
|
||||
isPanelVisible: boolean;
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
dispatch({ type: 'SET_SIDEBARS_VISIBLE', payload: visible });
|
||||
}, []);
|
||||
|
||||
const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent') => {
|
||||
const setLeftPanelView = useCallback((view: 'toolPicker' | 'toolContent' | 'hidden') => {
|
||||
dispatch({ type: 'SET_LEFT_PANEL_VIEW', payload: view });
|
||||
}, []);
|
||||
|
||||
@@ -179,7 +180,26 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
}, []); // Empty dependency array makes this stable
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: string) => {
|
||||
const handleToolSelect = useCallback((toolId: ToolId) => {
|
||||
// Handle read tool selection - should behave exactly like QuickAccessBar read button
|
||||
if (toolId === 'read') {
|
||||
setReaderMode(true);
|
||||
actions.setSelectedTool('read');
|
||||
actions.setWorkbench('viewer');
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multiTool selection - enable page editor workbench and hide left panel
|
||||
if (toolId === 'multiTool') {
|
||||
setReaderMode(false);
|
||||
setLeftPanelView('hidden');
|
||||
actions.setSelectedTool('multiTool');
|
||||
actions.setWorkbench('pageEditor');
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the selected tool and determine the appropriate workbench
|
||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||
actions.setSelectedTool(validToolId);
|
||||
@@ -194,19 +214,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Clear search query when selecting a tool
|
||||
setSearchQuery('');
|
||||
|
||||
// Handle view switching logic
|
||||
if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') {
|
||||
setLeftPanelView('toolPicker');
|
||||
if (toolId === 'read' || toolId === 'view-pdf') {
|
||||
setReaderMode(true);
|
||||
} else {
|
||||
setReaderMode(false);
|
||||
}
|
||||
} else {
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
@@ -219,17 +228,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setReaderMode(true);
|
||||
}, [setReaderMode]);
|
||||
|
||||
// Filter tools based on search query
|
||||
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!toolRegistry) return [];
|
||||
return Object.entries(toolRegistry).filter(([_, { name }]) =>
|
||||
name.toLowerCase().includes(state.searchQuery.toLowerCase())
|
||||
);
|
||||
return filterToolRegistryByQuery(toolRegistry as Record<string, ToolRegistryEntry>, state.searchQuery);
|
||||
}, [toolRegistry, state.searchQuery]);
|
||||
|
||||
const isPanelVisible = useMemo(() =>
|
||||
state.sidebarsVisible && !state.readerMode,
|
||||
[state.sidebarsVisible, state.readerMode]
|
||||
state.sidebarsVisible && !state.readerMode && state.leftPanelView !== 'hidden',
|
||||
[state.sidebarsVisible, state.readerMode, state.leftPanelView]
|
||||
);
|
||||
|
||||
// URL sync for proper tool navigation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { useNavigation } from './NavigationContext';
|
||||
|
||||
// Bridge API interfaces - these match what the bridges provide
|
||||
interface ScrollAPIWrapper {
|
||||
@@ -51,6 +52,11 @@ interface ThumbnailAPIWrapper {
|
||||
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
|
||||
}
|
||||
|
||||
interface ExportAPIWrapper {
|
||||
download: () => void;
|
||||
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
|
||||
}
|
||||
|
||||
|
||||
// State interfaces - represent the shape of data from each bridge
|
||||
interface ScrollState {
|
||||
@@ -93,6 +99,10 @@ interface SearchState {
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
interface ExportState {
|
||||
canExport: boolean;
|
||||
}
|
||||
|
||||
// Bridge registration interface - bridges register with state and API
|
||||
interface BridgeRef<TState = unknown, TApi = unknown> {
|
||||
state: TState;
|
||||
@@ -122,6 +132,7 @@ interface ViewerContextType {
|
||||
getRotationState: () => RotationState;
|
||||
getSearchState: () => SearchState;
|
||||
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
|
||||
getExportState: () => ExportState;
|
||||
|
||||
// Immediate update callbacks
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
|
||||
@@ -179,6 +190,11 @@ interface ViewerContextType {
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
exportActions: {
|
||||
download: () => void;
|
||||
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
||||
};
|
||||
|
||||
// Bridge registration - internal use by bridges
|
||||
registerBridge: (type: string, ref: BridgeRef) => void;
|
||||
}
|
||||
@@ -193,6 +209,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||
|
||||
// Get current navigation state to check if we're in sign mode
|
||||
useNavigation();
|
||||
|
||||
// Bridge registry - bridges register their state and APIs here
|
||||
const bridgeRefs = useRef({
|
||||
scroll: null as BridgeRef<ScrollState, ScrollAPIWrapper> | null,
|
||||
@@ -203,6 +222,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
|
||||
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
|
||||
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
|
||||
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
|
||||
});
|
||||
|
||||
// Immediate zoom callback for responsive display updates
|
||||
@@ -238,6 +258,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
case 'thumbnail':
|
||||
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
|
||||
break;
|
||||
case 'export':
|
||||
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,6 +301,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
return bridgeRefs.current.thumbnail?.api || null;
|
||||
};
|
||||
|
||||
const getExportState = (): ExportState => {
|
||||
return bridgeRefs.current.export?.state || { canExport: false };
|
||||
};
|
||||
|
||||
// Action handlers - call APIs directly
|
||||
const scrollActions = {
|
||||
scrollToPage: (page: number) => {
|
||||
@@ -473,6 +500,28 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportActions = {
|
||||
download: () => {
|
||||
const api = bridgeRefs.current.export?.api;
|
||||
if (api?.download) {
|
||||
api.download();
|
||||
}
|
||||
},
|
||||
saveAsCopy: async () => {
|
||||
const api = bridgeRefs.current.export?.api;
|
||||
if (api?.saveAsCopy) {
|
||||
try {
|
||||
const result = api.saveAsCopy();
|
||||
return await result.toPromise();
|
||||
} catch (error) {
|
||||
console.error('Failed to save PDF copy:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
|
||||
immediateZoomUpdateCallback.current = callback;
|
||||
};
|
||||
@@ -507,6 +556,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
getRotationState,
|
||||
getSearchState,
|
||||
getThumbnailAPI,
|
||||
getExportState,
|
||||
|
||||
// Immediate updates
|
||||
registerImmediateZoomUpdate,
|
||||
@@ -522,6 +572,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
spreadActions,
|
||||
rotationActions,
|
||||
searchActions,
|
||||
exportActions,
|
||||
|
||||
// Bridge registration
|
||||
registerBridge,
|
||||
|
||||
@@ -21,7 +21,8 @@ export const initialFileContextState: FileContextState = {
|
||||
selectedPageNumbers: [],
|
||||
isProcessing: false,
|
||||
processingProgress: 0,
|
||||
hasUnsavedChanges: false
|
||||
hasUnsavedChanges: false,
|
||||
errorFileIds: []
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,6 +218,30 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
};
|
||||
}
|
||||
|
||||
case 'MARK_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
if (state.ui.errorFileIds.includes(fileId)) return state;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [...state.ui.errorFileIds, fileId] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: state.ui.errorFileIds.filter(id => id !== fileId) }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ALL_FILE_ERRORS': {
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'PIN_FILE': {
|
||||
const { fileId } = action.payload;
|
||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||
|
||||
@@ -558,5 +558,8 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
||||
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
||||
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
|
||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
|
||||
resetContext: () => dispatch({ type: 'RESET_CONTEXT' }),
|
||||
markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }),
|
||||
clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }),
|
||||
clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' })
|
||||
});
|
||||
|
||||
@@ -45,6 +45,8 @@ export type ToolRegistryEntry = {
|
||||
operationConfig?: ToolOperationConfig<any>;
|
||||
// Settings component for automation configuration
|
||||
settingsComponent?: React.ComponentType<any>;
|
||||
// Synonyms for search (optional)
|
||||
synonyms?: string[];
|
||||
}
|
||||
|
||||
export type ToolRegistry = Record<ToolId, ToolRegistryEntry>;
|
||||
|
||||
@@ -10,22 +10,29 @@ import AddPassword from "../tools/AddPassword";
|
||||
import ChangePermissions from "../tools/ChangePermissions";
|
||||
import RemoveBlanks from "../tools/RemoveBlanks";
|
||||
import RemovePages from "../tools/RemovePages";
|
||||
import ReorganizePages from "../tools/ReorganizePages";
|
||||
import { reorganizePagesOperationConfig } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import { getSynonyms } from "../utils/toolSynonyms";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import AddStamp from "../tools/AddStamp";
|
||||
import AddAttachments from "../tools/AddAttachments";
|
||||
import Merge from '../tools/Merge';
|
||||
import Repair from "../tools/Repair";
|
||||
import AutoRename from "../tools/AutoRename";
|
||||
import SingleLargePage from "../tools/SingleLargePage";
|
||||
import PageLayout from "../tools/PageLayout";
|
||||
import UnlockPdfForms from "../tools/UnlockPdfForms";
|
||||
import RemoveCertificateSign from "../tools/RemoveCertificateSign";
|
||||
import RemoveImage from "../tools/RemoveImage";
|
||||
import CertSign from "../tools/CertSign";
|
||||
import BookletImposition from "../tools/BookletImposition";
|
||||
import Flatten from "../tools/Flatten";
|
||||
import Rotate from "../tools/Rotate";
|
||||
import ChangeMetadata from "../tools/ChangeMetadata";
|
||||
import Crop from "../tools/Crop";
|
||||
import Sign from "../tools/Sign";
|
||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
||||
@@ -34,6 +41,7 @@ import { sanitizeOperationConfig } from "../hooks/tools/sanitize/useSanitizeOper
|
||||
import { repairOperationConfig } from "../hooks/tools/repair/useRepairOperation";
|
||||
import { addWatermarkOperationConfig } from "../hooks/tools/addWatermark/useAddWatermarkOperation";
|
||||
import { addStampOperationConfig } from "../components/tools/addStamp/useAddStampOperation";
|
||||
import { addAttachmentsOperationConfig } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
|
||||
import { unlockPdfFormsOperationConfig } from "../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
|
||||
import { singleLargePageOperationConfig } from "../hooks/tools/singleLargePage/useSingleLargePageOperation";
|
||||
import { ocrOperationConfig } from "../hooks/tools/ocr/useOCROperation";
|
||||
@@ -48,7 +56,11 @@ import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperati
|
||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
||||
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { signOperationConfig } from "../hooks/tools/sign/useSignOperation";
|
||||
import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
|
||||
import { removeAnnotationsOperationConfig } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
||||
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
|
||||
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
|
||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||
@@ -67,12 +79,23 @@ import RedactSingleStepSettings from "../components/tools/redact/RedactSingleSte
|
||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||
import ReplaceColor from "../tools/ReplaceColor";
|
||||
import ScannerImageSplit from "../tools/ScannerImageSplit";
|
||||
import { ToolId } from "../types/toolId";
|
||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import SignSettings from "../components/tools/sign/SignSettings";
|
||||
import CropSettings from "../components/tools/crop/CropSettings";
|
||||
import RemoveAnnotations from "../tools/RemoveAnnotations";
|
||||
import RemoveAnnotationsSettings from "../components/tools/removeAnnotations/RemoveAnnotationsSettings";
|
||||
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings"
|
||||
import ExtractImages from "../tools/ExtractImages";
|
||||
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
|
||||
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@@ -163,8 +186,32 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
|
||||
return useMemo(() => {
|
||||
const allTools: ToolRegistry = {
|
||||
// Recommended Tools in order
|
||||
multiTool: {
|
||||
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.multiTool.title", "Multi-Tool"),
|
||||
component: null,
|
||||
workbench: "pageEditor",
|
||||
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
synonyms: getSynonyms(t, "multiTool"),
|
||||
},
|
||||
merge: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.merge.title", "Merge"),
|
||||
component: Merge,
|
||||
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["merge-pdfs"],
|
||||
operationConfig: mergeOperationConfig,
|
||||
settingsComponent: MergeSettings,
|
||||
synonyms: getSynonyms(t, "merge")
|
||||
},
|
||||
// Signing
|
||||
|
||||
certSign: {
|
||||
icon: <LocalIcon icon="workspace-premium-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.certSign.title", "Certificate Sign"),
|
||||
@@ -172,6 +219,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.certSign.desc", "Sign PDF documents using digital certificates"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
synonyms: getSynonyms(t, "certSign"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["cert-sign"],
|
||||
operationConfig: certSignOperationConfig,
|
||||
@@ -180,10 +228,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
sign: {
|
||||
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.sign.title", "Sign"),
|
||||
component: null,
|
||||
component: Sign,
|
||||
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
operationConfig: signOperationConfig,
|
||||
settingsComponent: SignSettings,
|
||||
synonyms: getSynonyms(t, "sign")
|
||||
},
|
||||
|
||||
// Document Security
|
||||
@@ -199,7 +250,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: addPasswordOperationConfig,
|
||||
settingsComponent: AddPasswordSettings,
|
||||
},
|
||||
synonyms: getSynonyms(t, "addPassword")
|
||||
},
|
||||
watermark: {
|
||||
icon: <LocalIcon icon="branding-watermark-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.watermark.title", "Add Watermark"),
|
||||
@@ -211,6 +263,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["add-watermark"],
|
||||
operationConfig: addWatermarkOperationConfig,
|
||||
settingsComponent: AddWatermarkSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "watermark")
|
||||
},
|
||||
addStamp: {
|
||||
icon: <LocalIcon icon="approval-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -219,6 +272,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addStamp.desc", "Add text or add image stamps at set locations"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
synonyms: getSynonyms(t, "addStamp"),
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-stamp"],
|
||||
operationConfig: addStampOperationConfig,
|
||||
@@ -234,6 +288,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["sanitize-pdf"],
|
||||
operationConfig: sanitizeOperationConfig,
|
||||
settingsComponent: SanitizeSettings,
|
||||
synonyms: getSynonyms(t, "sanitize")
|
||||
},
|
||||
flatten: {
|
||||
icon: <LocalIcon icon="layers-clear-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -246,6 +301,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["flatten"],
|
||||
operationConfig: flattenOperationConfig,
|
||||
settingsComponent: FlattenSettings,
|
||||
synonyms: getSynonyms(t, "flatten")
|
||||
},
|
||||
unlockPDFForms: {
|
||||
icon: <LocalIcon icon="preview-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -258,17 +314,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["unlock-pdf-forms"],
|
||||
operationConfig: unlockPdfFormsOperationConfig,
|
||||
settingsComponent: UnlockPdfFormsSettings,
|
||||
},
|
||||
manageCertificates: {
|
||||
icon: <LocalIcon icon="license-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.manageCertificates.title", "Manage Certificates"),
|
||||
component: null,
|
||||
description: t(
|
||||
"home.manageCertificates.desc",
|
||||
"Import, export, or delete digital certificate files used for signing PDFs."
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
synonyms: getSynonyms(t, "unlockPDFForms"),
|
||||
},
|
||||
changePermissions: {
|
||||
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
|
||||
@@ -281,6 +327,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: changePermissionsOperationConfig,
|
||||
settingsComponent: ChangePermissionsSettings,
|
||||
synonyms: getSynonyms(t, "changePermissions"),
|
||||
},
|
||||
getPdfInfo: {
|
||||
icon: <LocalIcon icon="fact-check-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -289,6 +336,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
synonyms: getSynonyms(t, "getPdfInfo"),
|
||||
},
|
||||
validateSignature: {
|
||||
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -297,11 +345,12 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
synonyms: getSynonyms(t, "validateSignature"),
|
||||
},
|
||||
|
||||
// Document Review
|
||||
|
||||
read: {
|
||||
read: {
|
||||
icon: <LocalIcon icon="article-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.read.title", "Read"),
|
||||
component: null,
|
||||
@@ -312,6 +361,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||
synonyms: getSynonyms(t, "read")
|
||||
},
|
||||
changeMetadata: {
|
||||
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -324,6 +374,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["update-metadata"],
|
||||
operationConfig: changeMetadataOperationConfig,
|
||||
settingsComponent: ChangeMetadataSingleStep,
|
||||
synonyms: getSynonyms(t, "changeMetadata")
|
||||
},
|
||||
// Page Formatting
|
||||
|
||||
@@ -350,6 +401,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["rotate-pdf"],
|
||||
operationConfig: rotateOperationConfig,
|
||||
settingsComponent: RotateSettings,
|
||||
synonyms: getSynonyms(t, "rotate")
|
||||
},
|
||||
split: {
|
||||
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -360,18 +412,21 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
operationConfig: splitOperationConfig,
|
||||
settingsComponent: SplitSettings,
|
||||
synonyms: getSynonyms(t, "split")
|
||||
},
|
||||
reorganizePages: {
|
||||
icon: <LocalIcon icon="move-down-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.reorganizePages.title", "Reorganize Pages"),
|
||||
component: null,
|
||||
workbench: "pageEditor",
|
||||
component: ReorganizePages,
|
||||
description: t(
|
||||
"home.reorganizePages.desc",
|
||||
"Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["rearrange-pages"],
|
||||
operationConfig: reorganizePagesOperationConfig,
|
||||
synonyms: getSynonyms(t, "reorganizePages")
|
||||
},
|
||||
scalePages: {
|
||||
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -384,6 +439,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["scale-pages"],
|
||||
operationConfig: adjustPageScaleOperationConfig,
|
||||
settingsComponent: AdjustPageScaleSettings,
|
||||
synonyms: getSynonyms(t, "scalePages")
|
||||
},
|
||||
addPageNumbers: {
|
||||
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -393,15 +449,19 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addPageNumbers")
|
||||
},
|
||||
pageLayout: {
|
||||
icon: <LocalIcon icon="dashboard-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.pageLayout.title", "Multi-Page Layout"),
|
||||
component: null,
|
||||
|
||||
component: PageLayout,
|
||||
description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["multi-page-layout"],
|
||||
settingsComponent: PageLayoutSettings,
|
||||
synonyms: getSynonyms(t, "pageLayout")
|
||||
},
|
||||
bookletImposition: {
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -426,15 +486,19 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
urlPath: '/pdf-to-single-page',
|
||||
endpoints: ["pdf-to-single-page"],
|
||||
operationConfig: singleLargePageOperationConfig,
|
||||
synonyms: getSynonyms(t, "pdfToSinglePage")
|
||||
},
|
||||
addAttachments: {
|
||||
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.addAttachments.title", "Add Attachments"),
|
||||
component: null,
|
||||
|
||||
component: AddAttachments,
|
||||
description: t("home.addAttachments.desc", "Add or remove embedded files (attachments) to/from a PDF"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addAttachments"),
|
||||
maxFiles: 1,
|
||||
endpoints: ["add-attachments"],
|
||||
operationConfig: addAttachmentsOperationConfig,
|
||||
},
|
||||
|
||||
// Extraction
|
||||
@@ -446,14 +510,20 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
synonyms: getSynonyms(t, "extractPages")
|
||||
},
|
||||
extractImages: {
|
||||
icon: <LocalIcon icon="filter-alt" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="photo-library-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.extractImages.title", "Extract Images"),
|
||||
component: null,
|
||||
component: ExtractImages,
|
||||
description: t("home.extractImages.desc", "Extract images from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-images"],
|
||||
operationConfig: extractImagesOperationConfig,
|
||||
settingsComponent: ExtractImagesSettings,
|
||||
synonyms: getSynonyms(t, "extractImages")
|
||||
},
|
||||
|
||||
// Removal
|
||||
@@ -467,6 +537,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-pages"],
|
||||
synonyms: getSynonyms(t, "removePages")
|
||||
},
|
||||
removeBlanks: {
|
||||
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -477,22 +548,31 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-blanks"],
|
||||
synonyms: getSynonyms(t, "removeBlanks")
|
||||
},
|
||||
removeAnnotations: {
|
||||
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.removeAnnotations.title", "Remove Annotations"),
|
||||
component: null,
|
||||
component: RemoveAnnotations,
|
||||
description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
operationConfig: removeAnnotationsOperationConfig,
|
||||
settingsComponent: RemoveAnnotationsSettings,
|
||||
synonyms: getSynonyms(t, "removeAnnotations")
|
||||
},
|
||||
removeImage: {
|
||||
icon: <LocalIcon icon="remove-selection-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.removeImage.title", "Remove Image"),
|
||||
component: null,
|
||||
description: t("home.removeImage.desc", "Remove images from PDF documents"),
|
||||
name: t("home.removeImage.title", "Remove Images"),
|
||||
component: RemoveImage,
|
||||
description: t("home.removeImage.desc", "Remove all images from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-image-pdf"],
|
||||
operationConfig: undefined,
|
||||
synonyms: getSynonyms(t, "removeImage"),
|
||||
},
|
||||
removePassword: {
|
||||
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -505,6 +585,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
operationConfig: removePasswordOperationConfig,
|
||||
settingsComponent: RemovePasswordSettings,
|
||||
synonyms: getSynonyms(t, "removePassword")
|
||||
},
|
||||
removeCertSign: {
|
||||
icon: <LocalIcon icon="remove-moderator-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -516,6 +597,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
},
|
||||
|
||||
// Automation
|
||||
@@ -533,6 +615,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
supportedFormats: CONVERT_SUPPORTED_FORMATS,
|
||||
endpoints: ["handleData"],
|
||||
synonyms: getSynonyms(t, "automate"),
|
||||
},
|
||||
autoRename: {
|
||||
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -544,22 +627,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
},
|
||||
autoSplitPDF: {
|
||||
icon: <LocalIcon icon="split-scene-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.autoSplitPDF.title", "Auto Split Pages"),
|
||||
component: null,
|
||||
description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
},
|
||||
autoSizeSplitPDF: {
|
||||
icon: <LocalIcon icon="content-cut-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.autoSizeSplitPDF.title", "Auto Split by Size/Count"),
|
||||
component: null,
|
||||
description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
synonyms: getSynonyms(t, "autoRename"),
|
||||
},
|
||||
|
||||
// Advanced Formatting
|
||||
@@ -571,6 +639,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "adjustContrast"),
|
||||
},
|
||||
repair: {
|
||||
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -583,14 +652,20 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["repair"],
|
||||
operationConfig: repairOperationConfig,
|
||||
settingsComponent: RepairSettings,
|
||||
synonyms: getSynonyms(t, "repair")
|
||||
},
|
||||
scannerImageSplit: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.scannerImageSplit.title", "Detect & Split Scanned Photos"),
|
||||
component: null,
|
||||
component: ScannerImageSplit,
|
||||
description: t("home.scannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-image-scans"],
|
||||
operationConfig: scannerImageSplitOperationConfig,
|
||||
settingsComponent: ScannerImageSplitSettings,
|
||||
synonyms: getSynonyms(t, "ScannerImageSplit"),
|
||||
},
|
||||
overlayPdfs: {
|
||||
icon: <LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -599,14 +674,20 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.overlayPdfs.desc", "Overlay one PDF on top of another"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "overlayPdfs"),
|
||||
},
|
||||
replaceColorPdf: {
|
||||
replaceColor: {
|
||||
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.replaceColorPdf.title", "Replace & Invert Color"),
|
||||
component: null,
|
||||
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
|
||||
name: t("home.replaceColor.title", "Replace & Invert Color"),
|
||||
component: ReplaceColor,
|
||||
description: t("home.replaceColor.desc", "Replace or invert colors in PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["replace-invert-pdf"],
|
||||
operationConfig: replaceColorOperationConfig,
|
||||
settingsComponent: ReplaceColorSettings,
|
||||
synonyms: getSynonyms(t, "replaceColor"),
|
||||
},
|
||||
addImage: {
|
||||
icon: <LocalIcon icon="image-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -615,6 +696,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addImage.desc", "Add images to PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addImage"),
|
||||
},
|
||||
editTableOfContents: {
|
||||
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -623,14 +705,16 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "editTableOfContents"),
|
||||
},
|
||||
fakeScan: {
|
||||
scannerEffect: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.fakeScan.title", "Scanner Effect"),
|
||||
name: t("home.scannerEffect.title", "Scanner Effect"),
|
||||
component: null,
|
||||
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
|
||||
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "scannerEffect"),
|
||||
},
|
||||
|
||||
// Developer Tools
|
||||
@@ -642,6 +726,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
synonyms: getSynonyms(t, "showJS"),
|
||||
},
|
||||
devApi: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -651,6 +736,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html",
|
||||
synonyms: getSynonyms(t, "devApi"),
|
||||
},
|
||||
devFolderScanning: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -660,6 +746,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/",
|
||||
synonyms: getSynonyms(t, "devFolderScanning"),
|
||||
},
|
||||
devSsoGuide: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -669,6 +756,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
|
||||
synonyms: getSynonyms(t, "devSsoGuide"),
|
||||
},
|
||||
devAirgapped: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -678,6 +766,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Pro/#activation",
|
||||
synonyms: getSynonyms(t, "devAirgapped"),
|
||||
},
|
||||
|
||||
// Recommended Tools
|
||||
@@ -688,6 +777,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
synonyms: getSynonyms(t, "compare")
|
||||
},
|
||||
compress: {
|
||||
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -699,6 +789,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
operationConfig: compressOperationConfig,
|
||||
settingsComponent: CompressSettings,
|
||||
synonyms: getSynonyms(t, "compress")
|
||||
},
|
||||
convert: {
|
||||
icon: <LocalIcon icon="sync-alt-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -728,29 +819,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
|
||||
operationConfig: convertOperationConfig,
|
||||
settingsComponent: ConvertSettings,
|
||||
synonyms: getSynonyms(t, "convert")
|
||||
},
|
||||
merge: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.merge.title", "Merge"),
|
||||
component: Merge,
|
||||
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["merge-pdfs"],
|
||||
operationConfig: mergeOperationConfig,
|
||||
settingsComponent: MergeSettings
|
||||
},
|
||||
multiTool: {
|
||||
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.multiTool.title", "Multi-Tool"),
|
||||
component: null,
|
||||
workbench: "pageEditor",
|
||||
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
},
|
||||
|
||||
ocr: {
|
||||
icon: <LocalIcon icon="quick-reference-all-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.ocr.title", "OCR"),
|
||||
@@ -762,6 +833,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
urlPath: '/ocr-pdf',
|
||||
operationConfig: ocrOperationConfig,
|
||||
settingsComponent: OCRSettings,
|
||||
synonyms: getSynonyms(t, "ocr")
|
||||
},
|
||||
redact: {
|
||||
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -774,6 +846,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["auto-redact"],
|
||||
operationConfig: redactOperationConfig,
|
||||
settingsComponent: RedactSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "redact")
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Vendored
-1
@@ -18,6 +18,5 @@ declare module '../assets/material-symbols-icons.json' {
|
||||
}
|
||||
|
||||
declare module 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
// TODO: Add proper EmbedPDF types for local submodule integration
|
||||
|
||||
export {};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AddAttachmentsParameters } from './useAddAttachmentsParameters';
|
||||
|
||||
const buildFormData = (parameters: AddAttachmentsParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add the main PDF file (single file per request in singleFile mode)
|
||||
if (file) {
|
||||
formData.append("fileInput", file);
|
||||
}
|
||||
|
||||
// Add attachment files
|
||||
(parameters.attachments || []).forEach((attachment) => {
|
||||
if (attachment) formData.append("attachments", attachment);
|
||||
});
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Operation configuration for automation
|
||||
export const addAttachmentsOperationConfig: ToolOperationConfig<AddAttachmentsParameters> = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData,
|
||||
operationType: 'addAttachments',
|
||||
endpoint: '/api/v1/misc/add-attachments',
|
||||
};
|
||||
|
||||
export const useAddAttachmentsOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<AddAttachmentsParameters>({
|
||||
...addAttachmentsOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('addAttachments.error.failed', 'An error occurred while adding attachments to the PDF.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface AddAttachmentsParameters {
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
const defaultParameters: AddAttachmentsParameters = {
|
||||
attachments: []
|
||||
};
|
||||
|
||||
export const useAddAttachmentsParameters = () => {
|
||||
const [parameters, setParameters] = useState<AddAttachmentsParameters>(defaultParameters);
|
||||
|
||||
const updateParameter = <K extends keyof AddAttachmentsParameters>(
|
||||
key: K,
|
||||
value: AddAttachmentsParameters[K]
|
||||
) => {
|
||||
setParameters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(defaultParameters);
|
||||
};
|
||||
|
||||
const validateParameters = (): boolean => {
|
||||
return parameters.attachments.length > 0;
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import apiClient from '../../../services/apiClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters, defaultParameters } from './useConvertParameters';
|
||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
@@ -19,6 +19,8 @@ export const shouldProcessFilesSeparately = (
|
||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||
// PDF to PDF/A conversions (each PDF should be processed separately)
|
||||
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
|
||||
// PDF to text-like formats should be one output per input
|
||||
(parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) ||
|
||||
// Web files to PDF conversions (each web file should generate its own PDF)
|
||||
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
|
||||
parameters.toExtension === 'pdf') ||
|
||||
@@ -106,7 +108,7 @@ export const convertProcessor = async (
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
const formData = buildConvertFormData(parameters, [file]);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const convertedFile = createFileFromResponse(response.data, response.headers, file.name, parameters.toExtension);
|
||||
|
||||
@@ -118,7 +120,7 @@ export const convertProcessor = async (
|
||||
} else {
|
||||
// Batch processing for simple cases (image→PDF combine)
|
||||
const formData = buildConvertFormData(parameters, selectedFiles);
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
const baseFilename = selectedFiles.length === 1
|
||||
? selectedFiles[0].name
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ExtractImagesParameters, defaultParameters } from './useExtractImagesParameters';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
// Static configuration that can be used by both the hook and automation executor
|
||||
export const buildExtractImagesFormData = (parameters: ExtractImagesParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("format", parameters.format);
|
||||
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Response handler for extract-images which returns a ZIP file
|
||||
const extractImagesResponseHandler = async (responseData: Blob, _originalFiles: File[]): Promise<File[]> => {
|
||||
const zip = new JSZip();
|
||||
const zipContent = await zip.loadAsync(responseData);
|
||||
const extractedFiles: File[] = [];
|
||||
|
||||
for (const [filename, file] of Object.entries(zipContent.files)) {
|
||||
if (!file.dir) {
|
||||
const blob = await file.async('blob');
|
||||
const extractedFile = new File([blob], filename, { type: blob.type });
|
||||
extractedFiles.push(extractedFile);
|
||||
}
|
||||
}
|
||||
|
||||
return extractedFiles;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const extractImagesOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildExtractImagesFormData,
|
||||
operationType: 'extractImages',
|
||||
endpoint: '/api/v1/misc/extract-images',
|
||||
defaultParameters,
|
||||
// Extract-images returns a ZIP file containing multiple image files
|
||||
responseHandler: extractImagesResponseHandler,
|
||||
} as const;
|
||||
|
||||
export const useExtractImagesOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ExtractImagesParameters>({
|
||||
...extractImagesOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('extractImages.error.failed', 'An error occurred while extracting images from the PDF.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useBaseParameters } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ExtractImagesParameters {
|
||||
format: 'png' | 'jpg' | 'gif';
|
||||
allowDuplicates: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: ExtractImagesParameters = {
|
||||
format: 'png',
|
||||
allowDuplicates: false,
|
||||
};
|
||||
|
||||
export const useExtractImagesParameters = () => {
|
||||
return useBaseParameters<ExtractImagesParameters>({
|
||||
defaultParameters,
|
||||
endpointName: 'extract-images',
|
||||
validateFn: () => true, // All parameters have valid defaults
|
||||
});
|
||||
};
|
||||
@@ -9,6 +9,9 @@ const buildFormData = (parameters: MergeParameters, files: File[]): FormData =>
|
||||
files.forEach((file) => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
// Provide stable client file IDs (align with files order)
|
||||
const clientIds: string[] = files.map((f: any) => String((f as any).fileId || f.name));
|
||||
formData.append('clientFileIds', JSON.stringify(clientIds));
|
||||
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
|
||||
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
|
||||
formData.append("generateToc", parameters.generateTableOfContents.toString());
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { PageLayoutParameters, defaultParameters } from './usePageLayoutParameters';
|
||||
|
||||
export const buildPageLayoutFormData = (parameters: PageLayoutParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('pagesPerSheet', String(parameters.pagesPerSheet));
|
||||
formData.append('addBorder', String(parameters.addBorder));
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const pageLayoutOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildPageLayoutFormData,
|
||||
operationType: 'pageLayout',
|
||||
endpoint: '/api/v1/general/multi-page-layout',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const usePageLayoutOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<PageLayoutParameters>({
|
||||
...pageLayoutOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('pageLayout.error.failed', 'An error occurred while creating the multi-page layout.')
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface PageLayoutParameters extends BaseParameters {
|
||||
pagesPerSheet: number;
|
||||
addBorder: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: PageLayoutParameters = {
|
||||
pagesPerSheet: 4,
|
||||
addBorder: false,
|
||||
};
|
||||
|
||||
export type PageLayoutParametersHook = BaseParametersHook<PageLayoutParameters>;
|
||||
|
||||
export const usePageLayoutParameters = (): PageLayoutParametersHook => {
|
||||
return useBaseParameters<PageLayoutParameters>({
|
||||
defaultParameters,
|
||||
endpointName: 'multi-page-layout',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemoveAnnotationsParameters, defaultParameters } from './useRemoveAnnotationsParameters';
|
||||
|
||||
// Client-side PDF processing using PDF-lib
|
||||
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<File[]> => {
|
||||
// Dynamic import of PDF-lib for client-side processing
|
||||
const { PDFDocument, PDFName, PDFRef, PDFDict } = await import('pdf-lib');
|
||||
|
||||
const processedFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Load the PDF
|
||||
const fileArrayBuffer = await file.arrayBuffer();
|
||||
const pdfBytesIn = new Uint8Array(fileArrayBuffer);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytesIn, { ignoreEncryption: true });
|
||||
const ctx = pdfDoc.context;
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const page = pages[i];
|
||||
|
||||
// Annots() returns PDFArray | undefined
|
||||
const annots = page.node.Annots();
|
||||
if (!annots || annots.size() === 0) continue;
|
||||
|
||||
// Delete each annotation object (they are usually PDFRef)
|
||||
for (let j = annots.size() - 1; j >= 0; j--) {
|
||||
try {
|
||||
const entry = annots.get(j);
|
||||
if (entry instanceof PDFRef) {
|
||||
ctx.delete(entry);
|
||||
} else if (entry instanceof PDFDict) {
|
||||
// In practice, Annots array should contain refs; if not, just remove the array linkage.
|
||||
// (We avoid poking internal maps to find a ref for the dict.)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove annotation ${j} on page ${i + 1}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the Annots key entirely
|
||||
try {
|
||||
if (page.node.has(PDFName.of('Annots'))) {
|
||||
page.node.delete(PDFName.of('Annots'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete /Annots on page ${i + 1}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: if removing ALL annotations across the doc, strip AcroForm to avoid dangling widget refs
|
||||
try {
|
||||
const catalog = pdfDoc.context.lookup(pdfDoc.context.trailerInfo.Root);
|
||||
if (catalog && 'has' in catalog && 'delete' in catalog) {
|
||||
const catalogDict = catalog as any;
|
||||
if (catalogDict.has(PDFName.of('AcroForm'))) {
|
||||
catalogDict.delete(PDFName.of('AcroForm'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to remove /AcroForm:', err);
|
||||
}
|
||||
|
||||
// Save returns Uint8Array — safe for Blob
|
||||
const outBytes = await pdfDoc.save();
|
||||
const outBlob = new Blob([new Uint8Array(outBytes)], { type: 'application/pdf' });
|
||||
|
||||
// Create new file with original name
|
||||
const processedFile = new File([outBlob], file.name, { type: 'application/pdf' });
|
||||
|
||||
processedFiles.push(processedFile);
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', file.name, error);
|
||||
throw new Error(`Failed to process ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const removeAnnotationsOperationConfig = {
|
||||
toolType: ToolType.custom,
|
||||
operationType: 'removeAnnotations',
|
||||
customProcessor: removeAnnotationsProcessor,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useRemoveAnnotationsOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<RemoveAnnotationsParameters>({
|
||||
...removeAnnotationsOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('removeAnnotations.error.failed', 'An error occurred while removing annotations from the PDF.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useBaseParameters } from '../shared/useBaseParameters';
|
||||
|
||||
export type RemoveAnnotationsParameters = Record<string, never>
|
||||
|
||||
export const defaultParameters: RemoveAnnotationsParameters = {
|
||||
};
|
||||
|
||||
export const useRemoveAnnotationsParameters = () => {
|
||||
return useBaseParameters<RemoveAnnotationsParameters>({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-annotations', // Not used for client-side processing, but required by base hook
|
||||
validateFn: () => true, // No parameters to validate
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import type { RemoveImageParameters } from './useRemoveImageParameters';
|
||||
|
||||
export const buildRemoveImageFormData = (_params: RemoveImageParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const removeImageOperationConfig: ToolOperationConfig<RemoveImageParameters> = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRemoveImageFormData,
|
||||
operationType: 'removeImage',
|
||||
endpoint: '/api/v1/general/remove-image-pdf',
|
||||
};
|
||||
|
||||
export const useRemoveImageOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<RemoveImageParameters>({
|
||||
...removeImageOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('removeImage.error.failed', 'Failed to remove images from the PDF.')
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useBaseParameters } from '../shared/useBaseParameters';
|
||||
import type { BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export type RemoveImageParameters = Record<string, never>;
|
||||
|
||||
export const defaultParameters: RemoveImageParameters = {};
|
||||
|
||||
export type RemoveImageParametersHook = BaseParametersHook<RemoveImageParameters>;
|
||||
|
||||
export const useRemoveImageParameters = (): RemoveImageParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-image-pdf',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolOperationConfig, ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ReorganizePagesParameters } from './useReorganizePagesParameters';
|
||||
|
||||
const buildFormData = (parameters: ReorganizePagesParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
if (parameters.customMode) {
|
||||
formData.append('customMode', parameters.customMode);
|
||||
}
|
||||
if (parameters.pageNumbers) {
|
||||
const cleaned = parameters.pageNumbers.replace(/\s+/g, '');
|
||||
formData.append('pageNumbers', cleaned);
|
||||
}
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const reorganizePagesOperationConfig: ToolOperationConfig<ReorganizePagesParameters> = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData,
|
||||
operationType: 'reorganizePages',
|
||||
endpoint: '/api/v1/general/rearrange-pages',
|
||||
};
|
||||
|
||||
export const useReorganizePagesOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
return useToolOperation<ReorganizePagesParameters>({
|
||||
...reorganizePagesOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('reorganizePages.error.failed', 'Failed to reorganize pages')
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ReorganizePagesParameters {
|
||||
customMode: string; // empty string means custom order using pageNumbers
|
||||
pageNumbers: string; // e.g. "1,3,2,4-6"
|
||||
}
|
||||
|
||||
export const defaultReorganizePagesParameters: ReorganizePagesParameters = {
|
||||
customMode: '',
|
||||
pageNumbers: '',
|
||||
};
|
||||
|
||||
export const useReorganizePagesParameters = () => {
|
||||
const [parameters, setParameters] = useState<ReorganizePagesParameters>(defaultReorganizePagesParameters);
|
||||
|
||||
const updateParameter = <K extends keyof ReorganizePagesParameters>(
|
||||
key: K,
|
||||
value: ReorganizePagesParameters[K]
|
||||
) => {
|
||||
setParameters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => setParameters(defaultReorganizePagesParameters);
|
||||
|
||||
// If customMode is '' (custom) or 'DUPLICATE', a page order is required; otherwise it's optional/ignored
|
||||
const validateParameters = (): boolean => {
|
||||
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
|
||||
return requiresOrder ? parameters.pageNumbers.trim().length > 0 : true;
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ReplaceColorParameters, defaultParameters } from './useReplaceColorParameters';
|
||||
|
||||
export const buildReplaceColorFormData = (parameters: ReplaceColorParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
|
||||
formData.append('replaceAndInvertOption', parameters.replaceAndInvertOption);
|
||||
|
||||
if (parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR') {
|
||||
formData.append('highContrastColorCombination', parameters.highContrastColorCombination);
|
||||
} else if (parameters.replaceAndInvertOption === 'CUSTOM_COLOR') {
|
||||
formData.append('textColor', parameters.textColor);
|
||||
formData.append('backGroundColor', parameters.backGroundColor);
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const replaceColorOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildReplaceColorFormData,
|
||||
operationType: 'replaceColor',
|
||||
endpoint: '/api/v1/misc/replace-invert-pdf',
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useReplaceColorOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ReplaceColorParameters>({
|
||||
...replaceColorOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('replaceColor.error.failed', 'An error occurred while processing the color replacement.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ReplaceColorParameters extends BaseParameters {
|
||||
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR' | 'CUSTOM_COLOR' | 'FULL_INVERSION';
|
||||
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK' | 'BLACK_TEXT_ON_WHITE' | 'YELLOW_TEXT_ON_BLACK' | 'GREEN_TEXT_ON_BLACK';
|
||||
textColor: string;
|
||||
backGroundColor: string;
|
||||
}
|
||||
|
||||
export const defaultParameters: ReplaceColorParameters = {
|
||||
replaceAndInvertOption: 'HIGH_CONTRAST_COLOR',
|
||||
highContrastColorCombination: 'WHITE_TEXT_ON_BLACK',
|
||||
textColor: '#000000',
|
||||
backGroundColor: '#ffffff',
|
||||
};
|
||||
|
||||
export type ReplaceColorParametersHook = BaseParametersHook<ReplaceColorParameters>;
|
||||
|
||||
export const useReplaceColorParameters = (): ReplaceColorParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'replace-invert-pdf',
|
||||
validateFn: () => {
|
||||
// All parameters are always valid as they have defaults
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
|
||||
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('angle_threshold', parameters.angle_threshold.toString());
|
||||
formData.append('tolerance', parameters.tolerance.toString());
|
||||
formData.append('min_area', parameters.min_area.toString());
|
||||
formData.append('min_contour_area', parameters.min_contour_area.toString());
|
||||
formData.append('border_size', parameters.border_size.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Custom response handler to handle ZIP files that might be misidentified
|
||||
const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise<File[]> => {
|
||||
try {
|
||||
// Always try to extract as ZIP first, regardless of content-type
|
||||
const extractionResult = await zipFileService.extractAllFiles(responseData);
|
||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
||||
return extractionResult.extractedFiles;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract as ZIP, treating as single file:', error);
|
||||
}
|
||||
|
||||
// Fallback: treat as single file (PNG image)
|
||||
const inputFileName = inputFiles[0]?.name || 'document';
|
||||
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
|
||||
const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' });
|
||||
return [singleFile];
|
||||
};
|
||||
|
||||
export const scannerImageSplitOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildScannerImageSplitFormData,
|
||||
operationType: 'scannerImageSplit',
|
||||
endpoint: '/api/v1/misc/extract-image-scans',
|
||||
multiFileEndpoint: false,
|
||||
responseHandler: scannerImageSplitResponseHandler,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useScannerImageSplitOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ScannerImageSplitParameters>({
|
||||
...scannerImageSplitOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ScannerImageSplitParameters extends BaseParameters {
|
||||
angle_threshold: number;
|
||||
tolerance: number;
|
||||
min_area: number;
|
||||
min_contour_area: number;
|
||||
border_size: number;
|
||||
}
|
||||
|
||||
export const defaultParameters: ScannerImageSplitParameters = {
|
||||
angle_threshold: 10,
|
||||
tolerance: 30,
|
||||
min_area: 10000,
|
||||
min_contour_area: 500,
|
||||
border_size: 1,
|
||||
};
|
||||
|
||||
export type ScannerImageSplitParametersHook = BaseParametersHook<ScannerImageSplitParameters>;
|
||||
|
||||
export const useScannerImageSplitParameters = (): ScannerImageSplitParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'extract-image-scans',
|
||||
validateFn: () => {
|
||||
// All parameters are numeric with defaults, validation handled by form
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import axios, { CancelTokenSource } from 'axios';
|
||||
import axios, {type CancelTokenSource} from 'axios'; // Real axios for static methods (CancelToken, isCancel)
|
||||
import apiClient from '../../../services/apiClient'; // Our configured instance
|
||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { isEmptyOutput } from '../../../services/errorUtils';
|
||||
import type { ProcessingProgress } from './useToolState';
|
||||
|
||||
export interface ApiCallsConfig<TParams = void> {
|
||||
@@ -19,9 +21,11 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
validFiles: File[],
|
||||
config: ApiCallsConfig<TParams>,
|
||||
onProgress: (progress: ProcessingProgress) => void,
|
||||
onStatus: (status: string) => void
|
||||
): Promise<File[]> => {
|
||||
onStatus: (status: string) => void,
|
||||
markFileError?: (fileId: string) => void,
|
||||
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
|
||||
const processedFiles: File[] = [];
|
||||
const successSourceIds: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
const total = validFiles.length;
|
||||
|
||||
@@ -31,16 +35,19 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
for (let i = 0; i < validFiles.length; i++) {
|
||||
const file = validFiles[i];
|
||||
|
||||
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId });
|
||||
onProgress({ current: i + 1, total, currentFileName: file.name });
|
||||
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||
|
||||
try {
|
||||
const formData = config.buildFormData(params, file);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
console.debug('[processFiles] POST', { endpoint, name: file.name });
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
cancelToken: cancelTokenRef.current.token,
|
||||
cancelToken: cancelTokenRef.current?.token,
|
||||
});
|
||||
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
|
||||
|
||||
// Forward to shared response processor (uses tool-specific responseHandler if provided)
|
||||
const responseFiles = await processResponse(
|
||||
@@ -50,14 +57,35 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
config.responseHandler,
|
||||
config.preserveBackendFilename ? response.headers : undefined
|
||||
);
|
||||
// Guard: some endpoints may return an empty/0-byte file with 200
|
||||
const empty = isEmptyOutput(responseFiles);
|
||||
if (empty) {
|
||||
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
|
||||
failedFiles.push(file.name);
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
processedFiles.push(...responseFiles);
|
||||
// record source id as successful
|
||||
successSourceIds.push((file as any).fileId);
|
||||
console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length });
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
throw new Error('Operation was cancelled');
|
||||
}
|
||||
console.error(`Failed to process ${file.name}:`, error);
|
||||
console.error('[processFiles] Failed', { name: file.name, error });
|
||||
failedFiles.push(file.name);
|
||||
// mark errored file so UI can highlight
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +99,8 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
console.debug('[processFiles] Completed batch', { total, successes: successSourceIds.length, outputs: processedFiles.length, failed: failedFiles.length });
|
||||
return { outputFiles: processedFiles, successSourceIds };
|
||||
}, []);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import apiClient from '../../../services/apiClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext';
|
||||
import { FILE_EVENTS } from '../../../services/errorUtils';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
||||
import { ToolOperation } from '../../../types/file';
|
||||
@@ -148,6 +149,7 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||
|
||||
@@ -168,7 +170,18 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
||||
// Handle zero-byte inputs explicitly: mark as error and continue with others
|
||||
const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0);
|
||||
if (zeroByteFiles.length > 0) {
|
||||
try {
|
||||
for (const f of zeroByteFiles) {
|
||||
(fileActions.markFileError as any)((f as any).fileId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('markFileError', e);
|
||||
}
|
||||
}
|
||||
const validFiles = selectedFiles.filter(file => (file as any)?.size > 0);
|
||||
if (validFiles.length === 0) {
|
||||
actions.setError(t('noValidFiles', 'No valid files to process'));
|
||||
return;
|
||||
@@ -183,8 +196,19 @@ export const useToolOperation = <TParams>(
|
||||
// Prepare files with history metadata injection (for PDFs)
|
||||
actions.setStatus('Processing files...');
|
||||
|
||||
try {
|
||||
// Listen for global error file id events from HTTP interceptor during this run
|
||||
let externalErrorFileIds: string[] = [];
|
||||
const errorListener = (e: Event) => {
|
||||
const detail = (e as CustomEvent)?.detail as any;
|
||||
if (detail?.fileIds) {
|
||||
externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds : [];
|
||||
}
|
||||
};
|
||||
window.addEventListener(FILE_EVENTS.markError, errorListener as EventListener);
|
||||
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
let successSourceIds: string[] = [];
|
||||
|
||||
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
|
||||
const filesForAPI = extractFiles(validFiles);
|
||||
@@ -199,13 +223,18 @@ export const useToolOperation = <TParams>(
|
||||
responseHandler: config.responseHandler,
|
||||
preserveBackendFilename: config.preserveBackendFilename
|
||||
};
|
||||
processedFiles = await processFiles(
|
||||
console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length });
|
||||
const result = await processFiles(
|
||||
params,
|
||||
filesForAPI,
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus
|
||||
actions.setStatus,
|
||||
fileActions.markFileError as any
|
||||
);
|
||||
processedFiles = result.outputFiles;
|
||||
successSourceIds = result.successSourceIds as any;
|
||||
console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length });
|
||||
break;
|
||||
}
|
||||
case ToolType.multiFile: {
|
||||
@@ -214,7 +243,7 @@ export const useToolOperation = <TParams>(
|
||||
const formData = config.buildFormData(params, filesForAPI);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
|
||||
|
||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||
if (config.responseHandler) {
|
||||
@@ -235,13 +264,63 @@ export const useToolOperation = <TParams>(
|
||||
processedFiles = await extractAllZipFiles(response.data);
|
||||
}
|
||||
}
|
||||
// Assume all inputs succeeded together unless server provided an error earlier
|
||||
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolType.custom:
|
||||
case ToolType.custom: {
|
||||
actions.setStatus('Processing files...');
|
||||
processedFiles = await config.customProcessor(params, filesForAPI);
|
||||
// Try to map outputs back to inputs by filename (before extension)
|
||||
const inputBaseNames = new Map<string, string>();
|
||||
for (const f of validFiles) {
|
||||
const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase();
|
||||
inputBaseNames.set(base, (f as any).fileId);
|
||||
}
|
||||
const mappedSuccess: string[] = [];
|
||||
for (const out of processedFiles) {
|
||||
const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase();
|
||||
const id = inputBaseNames.get(base);
|
||||
if (id) mappedSuccess.push(id);
|
||||
}
|
||||
// Fallback to naive alignment if names don't match
|
||||
if (mappedSuccess.length === 0) {
|
||||
successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any;
|
||||
} else {
|
||||
successSourceIds = mappedSuccess as any;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize error flags across tool types: mark failures, clear successes
|
||||
try {
|
||||
const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[];
|
||||
const okSet = new Set((successSourceIds as unknown as string[]) || []);
|
||||
// Clear errors on successes
|
||||
for (const okId of okSet) {
|
||||
try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; }
|
||||
}
|
||||
// Mark errors on inputs that didn't succeed
|
||||
for (const id of allInputIds) {
|
||||
if (!okSet.has(id)) {
|
||||
try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; }
|
||||
}
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
|
||||
if (externalErrorFileIds.length > 0) {
|
||||
// If backend told us which sources failed, prefer that mapping
|
||||
successSourceIds = validFiles
|
||||
.map(f => (f as any).fileId)
|
||||
.filter(id => !externalErrorFileIds.includes(id)) as any;
|
||||
// Also mark failed IDs immediately
|
||||
try {
|
||||
for (const badId of externalErrorFileIds) {
|
||||
(fileActions.markFileError as any)(badId);
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
}
|
||||
|
||||
if (processedFiles.length > 0) {
|
||||
@@ -286,29 +365,38 @@ export const useToolOperation = <TParams>(
|
||||
const processedFileMetadataArray = await Promise.all(
|
||||
processedFiles.map(file => generateProcessedFileMetadata(file))
|
||||
);
|
||||
const shouldBranchHistory = processedFiles.length != inputStirlingFileStubs.length;
|
||||
// Create output stubs with fresh metadata (no inheritance of stale processedFile data)
|
||||
const outputStirlingFileStubs = shouldBranchHistory
|
||||
? processedFiles.map((file, index) =>
|
||||
createNewStirlingFileStub(file, undefined, thumbnails[index], processedFileMetadataArray[index])
|
||||
)
|
||||
: processedFiles.map((resultingFile, index) =>
|
||||
createChildStub(
|
||||
inputStirlingFileStubs[index],
|
||||
newToolOperation,
|
||||
resultingFile,
|
||||
thumbnails[index],
|
||||
processedFileMetadataArray[index]
|
||||
)
|
||||
);
|
||||
// Always create child stubs linking back to the successful source inputs
|
||||
const successInputStubs = successSourceIds
|
||||
.map((id) => selectors.getStirlingFileStub(id as any))
|
||||
.filter(Boolean) as StirlingFileStub[];
|
||||
|
||||
if (successInputStubs.length !== processedFiles.length) {
|
||||
console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', {
|
||||
successInputStubs: successInputStubs.length,
|
||||
outputs: processedFiles.length,
|
||||
});
|
||||
}
|
||||
|
||||
const outputStirlingFileStubs = processedFiles.map((resultingFile, index) =>
|
||||
createChildStub(
|
||||
successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0],
|
||||
newToolOperation,
|
||||
resultingFile,
|
||||
thumbnails[index],
|
||||
processedFileMetadataArray[index]
|
||||
)
|
||||
);
|
||||
|
||||
// Create StirlingFile objects from processed files and child stubs
|
||||
const outputStirlingFiles = processedFiles.map((file, index) => {
|
||||
const childStub = outputStirlingFileStubs[index];
|
||||
return createStirlingFile(file, childStub.id);
|
||||
});
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
// Build consumption arrays aligned to the successful source IDs
|
||||
const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[];
|
||||
// Outputs and stubs are already ordered by success sequence
|
||||
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
||||
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
@@ -320,10 +408,40 @@ export const useToolOperation = <TParams>(
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// Centralized 422 handler: mark provided IDs in errorFileIds
|
||||
try {
|
||||
const status = (error?.response?.status as number | undefined);
|
||||
if (status === 422) {
|
||||
const payload = error?.response?.data;
|
||||
let parsed: any = payload;
|
||||
if (typeof payload === 'string') {
|
||||
try { parsed = JSON.parse(payload); } catch { parsed = payload; }
|
||||
} else if (payload && typeof (payload as any).text === 'function') {
|
||||
// Blob or Response-like object from axios when responseType='blob'
|
||||
const text = await (payload as Blob).text();
|
||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
||||
}
|
||||
let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined;
|
||||
if (!ids && typeof parsed === 'string') {
|
||||
const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
if (match && match.length > 0) ids = Array.from(new Set(match));
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
for (const badId of ids) {
|
||||
try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; }
|
||||
}
|
||||
actions.setStatus('Process failed due to invalid/corrupted file(s)');
|
||||
// Avoid duplicating toast messaging here
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
|
||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
actions.setStatus('');
|
||||
} finally {
|
||||
window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener);
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationHook, ToolType } from '../shared/useToolOperation';
|
||||
import { SignParameters, DEFAULT_PARAMETERS } from './useSignParameters';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
|
||||
// Static configuration that can be used by both the hook and automation executor
|
||||
export const buildSignFormData = (params: SignParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
|
||||
// Add signature data if available
|
||||
if (params.signatureData) {
|
||||
formData.append('signatureData', params.signatureData);
|
||||
}
|
||||
|
||||
// Add signature position and size
|
||||
if (params.signaturePosition) {
|
||||
formData.append('x', params.signaturePosition.x.toString());
|
||||
formData.append('y', params.signaturePosition.y.toString());
|
||||
formData.append('width', params.signaturePosition.width.toString());
|
||||
formData.append('height', params.signaturePosition.height.toString());
|
||||
formData.append('page', params.signaturePosition.page.toString());
|
||||
}
|
||||
|
||||
// Add signature type
|
||||
formData.append('signatureType', params.signatureType || 'draw');
|
||||
|
||||
// Add other parameters
|
||||
if (params.reason) {
|
||||
formData.append('reason', params.reason);
|
||||
}
|
||||
if (params.location) {
|
||||
formData.append('location', params.location);
|
||||
}
|
||||
if (params.signerName) {
|
||||
formData.append('signerName', params.signerName);
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const signOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildSignFormData,
|
||||
operationType: 'sign',
|
||||
endpoint: '/api/v1/security/add-signature',
|
||||
filePrefix: 'signed_',
|
||||
defaultParameters: DEFAULT_PARAMETERS,
|
||||
} as const;
|
||||
|
||||
export const useSignOperation = (): ToolOperationHook<SignParameters> => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<SignParameters>({
|
||||
...signOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useBaseParameters } from '../shared/useBaseParameters';
|
||||
|
||||
export interface SignaturePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface SignParameters {
|
||||
signatureType: 'image' | 'text' | 'draw' | 'canvas';
|
||||
signatureData?: string; // Base64 encoded image or text content
|
||||
signaturePosition?: SignaturePosition;
|
||||
reason?: string;
|
||||
location?: string;
|
||||
signerName?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PARAMETERS: SignParameters = {
|
||||
signatureType: 'canvas',
|
||||
reason: 'Document signing',
|
||||
location: 'Digital',
|
||||
signerName: '',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 16,
|
||||
};
|
||||
|
||||
const validateSignParameters = (parameters: SignParameters): boolean => {
|
||||
// Basic validation
|
||||
if (!parameters.signatureType) return false;
|
||||
|
||||
// If signature position is set, validate it
|
||||
if (parameters.signaturePosition) {
|
||||
const pos = parameters.signaturePosition;
|
||||
if (pos.x < 0 || pos.y < 0 || pos.width <= 0 || pos.height <= 0 || pos.page < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For image and canvas signatures, require signature data
|
||||
if ((parameters.signatureType === 'image' || parameters.signatureType === 'canvas') && !parameters.signatureData) {
|
||||
return false;
|
||||
}
|
||||
// For text signatures, require signer name
|
||||
if (parameters.signatureType === 'text' && !parameters.signerName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const useSignParameters = () => {
|
||||
return useBaseParameters<SignParameters>({
|
||||
defaultParameters: DEFAULT_PARAMETERS,
|
||||
endpointName: 'add-signature',
|
||||
validateFn: validateSignParameters,
|
||||
});
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user