Compare commits

...
Author SHA1 Message Date
Reece 3ce367990d Add package-lock to dockerfile 2025-09-26 20:52:57 +01:00
Reece Browne 43beadbdcb update embedpdf (#4516) 2025-09-26 19:46:31 +01:00
416d79aed3 Feature/v2/sign (#4485)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
2025-09-26 19:11:03 +01:00
ConnorYohandConnor Yoh abc0988fdf Feature/v2/reader-and-multitool-navigation (#4514)
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-09-26 16:29:58 +01:00
Anthony StirlingandClaude c7e0ea5b5b Add React-based remove annotations tool (#4504)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 15:45:51 +01:00
Anthony Stirling b35447934e remove tools (#4513) 2025-09-26 15:42:23 +01:00
EthanHealy01andAnthony Stirling d1e82eb8f1 add multi page layout tool (#4507)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-09-26 15:41:39 +01:00
ConnorYohandConnor Yoh 7d44cc1a40 Bugfix/V2/remove-timeout-on-fetch (#4510)
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-09-26 15:36:41 +01:00
EthanHealy01 0bdc6466ca add the reorganize pages tool (#4506) 2025-09-26 12:49:18 +01:00
EthanHealy01 f2a6e95fcf Feature/remove images (#4503) 2025-09-26 12:46:02 +01:00
EthanHealy01 0c08764669 add attatchments tool (#4502) 2025-09-26 12:45:31 +01:00
Anthony Stirling 9758e871d4 feat: Add React-based extract-images tool (#4501) 2025-09-26 12:45:15 +01:00
Anthony Stirling 18fa16f08e Invert colors (#4498) 2025-09-26 12:44:25 +01:00
Anthony StirlingandClaude 233b710b78 Convert extract-image-scans to React component (#4505)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 12:38:10 +01:00
Reece Browne d613a4659e Feature/v2/exportpdf (#4487)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-26 10:28:09 +01:00
Reece Browne 03f484e0c0 Fix (#4495)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-09-26 10:03:35 +01:00
96 changed files with 6310 additions and 705 deletions
@@ -229,7 +229,7 @@ public class MergeController {
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) {
@@ -237,12 +237,12 @@ public class MergeController {
errorFileIds.add(clientIds[index]);
}
}
String payload = String.format(
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
errorFileIds.toString()
);
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));
+1 -1
View File
@@ -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
+13
View File
@@ -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;
+192 -111
View File
@@ -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
View File
@@ -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",
+178 -33
View File
@@ -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"
},
@@ -436,8 +442,8 @@
},
"scannerImageSplit": {
"tags": "detect,split,photos",
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
"title": "Detect & Split Scanned Photos",
"desc": "Detect and split scanned photos into separate pages"
},
"sign": {
"tags": "signature,autograph",
@@ -579,9 +585,9 @@
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"fakeScan": {
"scannerEffect": {
"tags": "scan,simulate,create",
"title": "Fake Scan",
"title": "Scanner Effect",
"desc": "Create a PDF that looks like it was scanned"
},
"editTableOfContents": {
@@ -619,8 +625,7 @@
"title": "Auto Split by Size/Count",
"desc": "Automatically split PDFs by file size or page count"
},
"replaceColorPdf": {
"tags": "color,replace,invert",
"replaceColor": {
"title": "Replace & Invert Colour",
"desc": "Replace or invert colours in PDF documents"
},
@@ -959,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",
@@ -972,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 sidestitch 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": {
@@ -1596,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",
@@ -1656,18 +1680,48 @@
"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": {
"title": "Sign",
"header": "Sign PDFs",
@@ -1678,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",
@@ -1689,7 +1744,42 @@
"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": {
"title": "Flatten",
@@ -1803,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",
@@ -2503,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": {
@@ -3298,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!",
@@ -3354,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"
}
}
+61 -9
View File
@@ -769,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",
@@ -782,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 sidestitch 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": {
@@ -1056,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",
@@ -1224,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",
@@ -1804,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",
@@ -1852,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 youd 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",
@@ -1876,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 cant 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",
@@ -2356,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."
}
}
}
+5 -7
View File
@@ -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
+5 -2
View File
@@ -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>
+134 -1
View File
@@ -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>
);
};
@@ -149,7 +149,6 @@ export default function Workbench() {
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
selectedToolKey={selectedToolId}
/>
{/* Dismiss All Errors Button */}
@@ -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>
);
};
@@ -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;
+22 -4
View File
@@ -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';
@@ -43,6 +44,9 @@ export default function RightRail() {
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();
@@ -66,6 +70,9 @@ 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
@@ -95,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;
@@ -113,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;
@@ -445,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,
+3 -2
View File
@@ -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,7 @@ 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>
@@ -80,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)}
/>
@@ -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 sidestitch 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;
@@ -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;
@@ -17,7 +17,8 @@ interface ToolButtonProps {
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
const isUnavailable = !tool.component && !tool.link;
// 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) => {
@@ -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 3050'),
t('scannerImageSplit.tooltip.problem2', 'Too many false detections → increase Minimum Area to 15,00020,000'),
t('scannerImageSplit.tooltip.problem3', 'Crops are too tight → increase Border Size to 510'),
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 10002000')
]
},
{
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 300600 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(() => {
+22 -4
View File
@@ -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 } });
+178
View File
@@ -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,
};
};
+29 -21
View File
@@ -17,7 +17,7 @@ import { filterToolRegistryByQuery } from '../utils/toolSearch';
interface ToolWorkflowState {
// UI State
sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent';
leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
readerMode: boolean;
// File/Preview State
@@ -31,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 }
@@ -80,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;
@@ -96,7 +96,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
resetTool: (toolId: string) => void;
// Workflow Actions (compound actions)
handleToolSelect: (toolId: string) => void;
handleToolSelect: (toolId: ToolId) => void;
handleBackToTools: () => void;
handleReaderToggle: () => void;
@@ -136,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 });
}, []);
@@ -180,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);
@@ -195,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(() => {
@@ -227,8 +235,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
}, [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
+51
View File
@@ -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,
+97 -79
View File
@@ -10,23 +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";
@@ -35,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";
@@ -49,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";
@@ -68,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
@@ -164,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"),
@@ -182,10 +228,12 @@ 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")
},
@@ -268,18 +316,6 @@ export function useFlatToolRegistry(): ToolRegistry {
settingsComponent: UnlockPdfFormsSettings,
synonyms: getSynonyms(t, "unlockPDFForms"),
},
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, "manageCertificates"),
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
name: t("home.changePermissions.title", "Change Permissions"),
@@ -381,14 +417,15 @@ export function useFlatToolRegistry(): ToolRegistry {
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: {
@@ -417,11 +454,13 @@ export function useFlatToolRegistry(): ToolRegistry {
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: {
@@ -452,12 +491,14 @@ export function useFlatToolRegistry(): ToolRegistry {
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")
synonyms: getSynonyms(t, "addAttachments"),
maxFiles: 1,
endpoints: ["add-attachments"],
operationConfig: addAttachmentsOperationConfig,
},
// Extraction
@@ -472,12 +513,16 @@ export function useFlatToolRegistry(): ToolRegistry {
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")
},
@@ -508,19 +553,25 @@ export function useFlatToolRegistry(): ToolRegistry {
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: {
@@ -578,24 +629,6 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.AUTOMATION,
synonyms: getSynonyms(t, "autoRename"),
},
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,
synonyms: getSynonyms(t, "autoSplitPDF"),
},
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, "autoSizeSplitPDF"),
},
// Advanced Formatting
@@ -624,10 +657,14 @@ export function useFlatToolRegistry(): ToolRegistry {
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: {
@@ -639,14 +676,18 @@ export function useFlatToolRegistry(): ToolRegistry {
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,
synonyms: getSynonyms(t, "replaceColorPdf"),
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" />,
@@ -666,14 +707,14 @@ export function useFlatToolRegistry(): ToolRegistry {
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, "fakeScan"),
synonyms: getSynonyms(t, "scannerEffect"),
},
// Developer Tools
@@ -780,30 +821,7 @@ export function useFlatToolRegistry(): ToolRegistry {
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,
synonyms: getSynonyms(t, "merge")
},
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"),
},
ocr: {
icon: <LocalIcon icon="quick-reference-all-outline-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.ocr.title", "OCR"),
-1
View File
@@ -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';
@@ -108,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);
@@ -120,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
});
};
@@ -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,5 +1,6 @@
import { useCallback, useRef } from 'react';
import axios, { CancelTokenSource } from '../../../services/http';
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';
@@ -42,9 +43,9 @@ export const useToolApiCalls = <TParams = void>() => {
const formData = config.buildFormData(params, file);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
console.debug('[processFiles] POST', { endpoint, name: file.name });
const response = await axios.post(endpoint, formData, {
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 });
@@ -61,10 +62,10 @@ export const useToolApiCalls = <TParams = void>() => {
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);
try {
(markFileError as any)?.((file as any).fileId);
} catch (e) {
console.debug('markFileError', e);
}
continue;
}
@@ -80,10 +81,10 @@ export const useToolApiCalls = <TParams = void>() => {
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);
try {
(markFileError as any)?.((file as any).fileId);
} catch (e) {
console.debug('markFileError', e);
}
}
}
@@ -1,5 +1,5 @@
import { useCallback, useRef, useEffect } from 'react';
import axios from '../../../services/http';
import apiClient from '../../../services/apiClient';
import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext';
import { useToolState, type ProcessingProgress } from './useToolState';
@@ -177,8 +177,8 @@ export const useToolOperation = <TParams>(
for (const f of zeroByteFiles) {
(fileActions.markFileError as any)((f as any).fileId);
}
} catch (e) {
console.log('markFileError', e);
} catch (e) {
console.log('markFileError', e);
}
}
const validFiles = selectedFiles.filter(file => (file as any)?.size > 0);
@@ -243,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) {
@@ -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,
});
};
+3 -2
View File
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { ToolRegistryEntry, getToolUrlPath } from '../data/toolsTaxonomy';
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
import { ToolId } from '../types/toolId';
export interface ToolNavigationProps {
/** Full URL for the tool (for href attribute) */
@@ -34,7 +35,7 @@ export function useToolNavigation(): {
}
// Use SPA navigation for internal tools
handleToolSelect(toolId);
handleToolSelect(toolId as ToolId);
});
};
@@ -42,4 +43,4 @@ export function useToolNavigation(): {
}, [handleToolSelect]);
return { getToolNavigation };
}
}
+4 -1
View File
@@ -72,7 +72,10 @@ export function useToolSections(
const subcategoryId = s as SubcategoryId;
if (!quick[subcategoryId]) quick[subcategoryId] = [];
// Only include ready tools (have a component or external link) in Quick Access
const readyTools = tools.filter(({ tool }) => tool.component !== null || !!tool.link);
// Special case: read and multiTool are navigational tools that don't need components
const readyTools = tools.filter(({ tool, id }) =>
tool.component !== null || !!tool.link || id === 'read' || id === 'multiTool'
);
quick[subcategoryId].push(...readyTools);
});
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { withBasePath } from '../constants/app';
*/
export function useNavigationUrlSync(
selectedTool: ToolId | null,
handleToolSelect: (toolId: string) => void,
handleToolSelect: (toolId: ToolId) => void,
clearToolSelection: () => void,
registry: ToolRegistry,
enableSync: boolean = true
+22
View File
@@ -0,0 +1,22 @@
// frontend/src/services/http.ts
import axios from 'axios';
import { handleHttpError } from './httpErrorHandler';
// Create axios instance with default config
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/', // Use env var or relative path (proxied by Vite in dev)
responseType: 'json',
});
// ---------- Install error interceptor ----------
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
await handleHttpError(error); // Handle error (shows toast unless suppressed)
return Promise.reject(error);
}
);
// ---------- Exports ----------
export default apiClient;
-255
View File
@@ -1,255 +0,0 @@
// frontend/src/services/http.ts
import axios from 'axios';
import type { AxiosInstance } from 'axios';
import { alert } from '../components/toast';
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils';
import { showSpecialErrorToast } from './specialErrorToasts';
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
return s && s.length > max ? `${s.slice(0, max)}` : s;
}
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
const s = (msg || '').trim();
if (!s) return true;
// Common unhelpful payloads we see
if (s === '{}' || s === '[]') return true;
if (/^request failed/i.test(s)) return true;
if (/^network error/i.test(s)) return true;
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
return false;
}
function titleForStatus(status?: number): string {
if (!status) return 'Network error';
if (status >= 500) return 'Server error';
if (status >= 400) return 'Request error';
return 'Request failed';
}
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || '';
let parsed: any = undefined;
const raw = error.response?.data;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
} else {
parsed = raw;
}
const extractIds = (): string[] | undefined => {
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
const rawText = typeof raw === 'string' ? raw : '';
const uuidMatches = rawText.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);
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
};
const body = ((): string => {
const data = parsed;
if (!data) return typeof raw === 'string' ? raw : '';
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
if (data?.message) return data.message as string;
if (typeof raw === 'string') return raw;
try { return JSON.stringify(data); } catch { return ''; }
})();
const ids = extractIds();
const title = titleForStatus(status);
if (ids && ids.length > 0) {
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
}
if (status === 422) {
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
return { title, body: bodyMsg };
}
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
return { title, body: bodyMsg };
}
try {
const msg = (error?.message || String(error)) as string;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
} catch (e) {
// ignore extraction errors
console.debug('extractAxiosErrorMessage', e);
return { title: 'Network error', body: FRIENDLY_FALLBACK };
}
}
// ---------- Axios instance creation ----------
const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined);
type ExtendedAxiosInstance = AxiosInstance & {
CancelToken: typeof axios.CancelToken;
isCancel: typeof axios.isCancel;
};
const __PREV_CLIENT: ExtendedAxiosInstance | undefined =
__globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined;
let __createdClient: any;
if (__PREV_CLIENT) {
__createdClient = __PREV_CLIENT;
} else if (typeof (axios as any)?.create === 'function') {
try {
__createdClient = (axios as any).create();
} catch (e) {
console.debug('createClient', e);
__createdClient = axios as any;
}
} else {
__createdClient = axios as any;
}
const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance;
// Augment instance with axios static helpers for backwards compatibility
if (apiClient) {
try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); }
try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); }
}
// ---------- Base defaults ----------
try {
const env = (import.meta as any)?.env || {};
apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/';
apiClient.defaults.responseType = 'json';
// If OSS relies on cookies, uncomment:
// apiClient.defaults.withCredentials = true;
// Sensible timeout to avoid “forever hanging”:
apiClient.defaults.timeout = 20000;
} catch (e) {
console.debug('setDefaults', e);
apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/';
apiClient.defaults.responseType = apiClient.defaults.responseType || 'json';
apiClient.defaults.timeout = apiClient.defaults.timeout || 20000;
}
// ---------- Install a single response error interceptor (dedup + UX) ----------
if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) {
try {
__PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID);
} catch (e) {
console.debug('ejectInterceptor', e);
}
}
const __recentSpecialByEndpoint: Record<string, number> = (__globalAny?.__SPDF_RECENT_SPECIAL || {});
const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use
? apiClient.interceptors.response.use(
(response) => response,
async (error) => {
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
const raw = (error?.response?.data) as any;
let normalized: unknown = raw;
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
// 1) If server sends structured file IDs for failures, also mark them errored in UI
try {
const ids = extractErrorFileIds(normalized);
if (ids && ids.length > 0) {
broadcastErroredFiles(ids);
}
} catch (e) {
console.debug('extractErrorFileIds', e);
}
// 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url;
const status: number | undefined = error?.response?.status;
const now = Date.now();
const isSpecial =
status === 422 ||
status === 409 || // often actionable conflicts
/Failed files:/.test(body) ||
/invalid\/corrupted file\(s\)/i.test(body);
if (isSpecial && url) {
__recentSpecialByEndpoint[url] = now;
if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
}
if (!isSpecial && url) {
const last = __recentSpecialByEndpoint[url] || 0;
if (now - last < __SPECIAL_SUPPRESS_MS) {
return Promise.reject(error);
}
}
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
let rawString: string | undefined;
try {
rawString =
typeof normalized === 'string'
? normalized
: JSON.stringify(normalized);
} catch (e) {
console.debug('extractErrorFileIds', e);
}
const handled = showSpecialErrorToast(rawString, { status });
if (!handled) {
const displayBody = clampText(body);
alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false });
}
return Promise.reject(error);
}
)
: undefined as any;
if (__globalAny) {
__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__;
__globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
__globalAny.__SPDF_HTTP_CLIENT = apiClient;
}
// ---------- Fetch helper ----------
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init });
if (!res.ok) {
let detail = '';
try {
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const data = await res.json();
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
} else {
detail = await res.text();
}
} catch {
// ignore parse errors
}
const title = titleForStatus(res.status);
const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText);
alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false });
// Important: match Axios semantics so callers can try/catch
throw new Error(body || res.statusText);
}
return res;
}
// ---------- Convenience API surface and exports ----------
export const api = {
get: apiClient.get,
post: apiClient.post,
put: apiClient.put,
patch: apiClient.patch,
delete: apiClient.delete,
request: apiClient.request,
};
export default apiClient;
export type { CancelTokenSource } from 'axios';
+147
View File
@@ -0,0 +1,147 @@
// frontend/src/services/httpErrorHandler.ts
import axios from 'axios';
import { alert } from '../components/toast';
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils';
import { showSpecialErrorToast } from './specialErrorToasts';
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
return s && s.length > max ? `${s.slice(0, max)}` : s;
}
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
const s = (msg || '').trim();
if (!s) return true;
// Common unhelpful payloads we see
if (s === '{}' || s === '[]') return true;
if (/^request failed/i.test(s)) return true;
if (/^network error/i.test(s)) return true;
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
return false;
}
function titleForStatus(status?: number): string {
if (!status) return 'Network error';
if (status >= 500) return 'Server error';
if (status >= 400) return 'Request error';
return 'Request failed';
}
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const _statusText = error.response?.statusText || '';
let parsed: any = undefined;
const raw = error.response?.data;
if (typeof raw === 'string') {
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
} else {
parsed = raw;
}
const extractIds = (): string[] | undefined => {
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
const rawText = typeof raw === 'string' ? raw : '';
const uuidMatches = rawText.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);
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
};
const body = ((): string => {
const data = parsed;
if (!data) return typeof raw === 'string' ? raw : '';
const ids = extractIds();
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
if (data?.message) return data.message as string;
if (typeof raw === 'string') return raw;
try { return JSON.stringify(data); } catch { return ''; }
})();
const ids = extractIds();
const title = titleForStatus(status);
if (ids && ids.length > 0) {
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
}
if (status === 422) {
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
return { title, body: bodyMsg };
}
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
return { title, body: bodyMsg };
}
try {
const msg = (error?.message || String(error)) as string;
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
} catch (e) {
// ignore extraction errors
console.debug('extractAxiosErrorMessage', e);
return { title: 'Network error', body: FRIENDLY_FALLBACK };
}
}
// Module-scoped state to reduce global variable usage
const recentSpecialByEndpoint: Record<string, number> = {};
const SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
/**
* Handles HTTP errors with toast notifications and file error broadcasting
* Returns true if the error should be suppressed (deduplicated), false otherwise
*/
export async function handleHttpError(error: any): Promise<boolean> {
// Compute title/body (friendly) from the error object
const { title, body } = extractAxiosErrorMessage(error);
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
const raw = (error?.response?.data) as any;
let normalized: unknown = raw;
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
// 1) If server sends structured file IDs for failures, also mark them errored in UI
try {
const ids = extractErrorFileIds(normalized);
if (ids && ids.length > 0) {
broadcastErroredFiles(ids);
}
} catch (e) {
console.debug('extractErrorFileIds', e);
}
// 2) Generic-vs-special dedupe by endpoint
const url: string | undefined = error?.config?.url;
const status: number | undefined = error?.response?.status;
const now = Date.now();
const isSpecial =
status === 422 ||
status === 409 || // often actionable conflicts
/Failed files:/.test(body) ||
/invalid\/corrupted file\(s\)/i.test(body);
if (isSpecial && url) {
recentSpecialByEndpoint[url] = now;
}
if (!isSpecial && url) {
const last = recentSpecialByEndpoint[url] || 0;
if (now - last < SPECIAL_SUPPRESS_MS) {
return true; // Suppress this error (deduplicated)
}
}
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
let rawString: string | undefined;
try {
rawString =
typeof normalized === 'string'
? normalized
: JSON.stringify(normalized);
} catch (e) {
console.debug('extractErrorFileIds', e);
}
const handled = showSpecialErrorToast(rawString, { status });
if (!handled) {
const displayBody = clampText(body);
alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false });
}
return false; // Error was handled with toast, continue normal rejection
}
+119
View File
@@ -338,6 +338,125 @@ export class ZipFileService {
return errorMessage.includes('password') || errorMessage.includes('encrypted');
}
}
/**
* Extract all files from a ZIP archive (not limited to PDFs)
*/
async extractAllFiles(
file: File | Blob,
onProgress?: (progress: ZipExtractionProgress) => void
): Promise<ZipExtractionResult> {
const result: ZipExtractionResult = {
success: false,
extractedFiles: [],
errors: [],
totalFiles: 0,
extractedCount: 0
};
try {
// Load ZIP contents
const zip = new JSZip();
const zipContents = await zip.loadAsync(file);
// Get all files (not directories)
const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) =>
!zipEntry.dir
);
result.totalFiles = allFiles.length;
// Extract each file
for (let i = 0; i < allFiles.length; i++) {
const [filename, zipEntry] = allFiles[i];
try {
// Report progress
if (onProgress) {
onProgress({
currentFile: filename,
extractedCount: i,
totalFiles: allFiles.length,
progress: (i / allFiles.length) * 100
});
}
// Extract file content
const content = await zipEntry.async('blob');
// Create File object with appropriate MIME type
const mimeType = this.getMimeTypeFromExtension(filename);
const extractedFile = new File([content], filename, { type: mimeType });
result.extractedFiles.push(extractedFile);
result.extractedCount++;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to extract "${filename}": ${errorMessage}`);
}
}
// Final progress report
if (onProgress) {
onProgress({
currentFile: '',
extractedCount: result.extractedCount,
totalFiles: result.totalFiles,
progress: 100
});
}
result.success = result.extractedFiles.length > 0;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to process ZIP file: ${errorMessage}`);
}
return result;
}
/**
* Get MIME type based on file extension
*/
private getMimeTypeFromExtension(fileName: string): string {
const ext = fileName.toLowerCase().split('.').pop();
const mimeTypes: Record<string, string> = {
// Images
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
'tiff': 'image/tiff',
'tif': 'image/tiff',
// Documents
'pdf': 'application/pdf',
'txt': 'text/plain',
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'json': 'application/json',
'xml': 'application/xml',
// Office documents
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// Archives
'zip': 'application/zip',
'rar': 'application/x-rar-compressed',
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
}
// Export singleton instance
@@ -17,13 +17,40 @@ import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameter
import { FileContextProvider } from '../../contexts/FileContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n/config';
import axios from 'axios';
import { createTestStirlingFile } from '../utils/testFileHelpers';
import { StirlingFile } from '../../types/fileContext';
// Mock axios
vi.mock('axios');
const mockedAxios = vi.mocked(axios);
// Mock axios (for static methods like CancelToken, isCancel)
vi.mock('axios', () => ({
default: {
CancelToken: {
source: vi.fn(() => ({
token: 'mock-cancel-token',
cancel: vi.fn()
}))
},
isCancel: vi.fn(() => false),
}
}));
// Mock our apiClient service
vi.mock('../../services/apiClient', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
response: {
use: vi.fn()
}
}
}
}));
// Import the mocked apiClient
import apiClient from '../../services/apiClient';
const mockedApiClient = vi.mocked(apiClient);
// Mock only essential services that are actually called by the tests
vi.mock('../../services/fileStorage', () => ({
@@ -71,8 +98,8 @@ describe('Convert Tool Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default axios mock
mockedAxios.post = vi.fn();
// Setup default apiClient mock
mockedApiClient.post = vi.fn();
});
afterEach(() => {
@@ -83,7 +110,7 @@ describe('Convert Tool Integration Tests', () => {
test('should make correct API call for PDF to PNG conversion', async () => {
const mockBlob = new Blob(['fake-image-data'], { type: 'image/png' });
(mockedAxios.post as Mock).mockResolvedValueOnce({
(mockedApiClient.post as Mock).mockResolvedValueOnce({
data: mockBlob,
status: 200,
statusText: 'OK'
@@ -126,14 +153,14 @@ describe('Convert Tool Integration Tests', () => {
});
// Verify axios was called with correct parameters
expect(mockedAxios.post).toHaveBeenCalledWith(
expect(mockedApiClient.post).toHaveBeenCalledWith(
'/api/v1/convert/pdf/img',
expect.any(FormData),
{ responseType: 'blob' }
);
// Verify FormData contains correct parameters
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
expect(formDataCall.get('imageFormat')).toBe('png');
expect(formDataCall.get('colorType')).toBe('color');
expect(formDataCall.get('dpi')).toBe('300');
@@ -148,7 +175,7 @@ describe('Convert Tool Integration Tests', () => {
test('should handle API error responses correctly', async () => {
const errorMessage = 'Invalid file format';
(mockedAxios.post as Mock).mockRejectedValueOnce({
(mockedApiClient.post as Mock).mockRejectedValueOnce({
response: {
status: 400,
data: errorMessage
@@ -199,7 +226,7 @@ describe('Convert Tool Integration Tests', () => {
});
test('should handle network errors gracefully', async () => {
(mockedAxios.post as Mock).mockRejectedValueOnce(new Error('Network error'));
(mockedApiClient.post as Mock).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
@@ -246,7 +273,7 @@ describe('Convert Tool Integration Tests', () => {
test('should correctly map image conversion parameters to API call', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
(mockedAxios.post as Mock).mockResolvedValueOnce({
(mockedApiClient.post as Mock).mockResolvedValueOnce({
data: mockBlob,
status: 200,
headers: {
@@ -292,7 +319,7 @@ describe('Convert Tool Integration Tests', () => {
});
// Verify integration: hook parameters → FormData → axios call → hook state
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
expect(formDataCall.get('imageFormat')).toBe('jpg');
expect(formDataCall.get('colorType')).toBe('grayscale');
expect(formDataCall.get('dpi')).toBe('150');
@@ -307,7 +334,7 @@ describe('Convert Tool Integration Tests', () => {
test('should make correct API call for PDF to CSV conversion with simplified workflow', async () => {
const mockBlob = new Blob(['fake-csv-data'], { type: 'text/csv' });
(mockedAxios.post as Mock).mockResolvedValueOnce({
(mockedApiClient.post as Mock).mockResolvedValueOnce({
data: mockBlob,
status: 200,
statusText: 'OK'
@@ -350,14 +377,14 @@ describe('Convert Tool Integration Tests', () => {
});
// Verify correct endpoint is called
expect(mockedAxios.post).toHaveBeenCalledWith(
expect(mockedApiClient.post).toHaveBeenCalledWith(
'/api/v1/convert/pdf/csv',
expect.any(FormData),
{ responseType: 'blob' }
);
// Verify FormData contains correct parameters for simplified CSV conversion
const formDataCall = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formDataCall = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
expect(formDataCall.get('pageNumbers')).toBe('all'); // Always "all" for simplified workflow
expect(formDataCall.get('fileInput')).toBe(testFile);
@@ -406,7 +433,7 @@ describe('Convert Tool Integration Tests', () => {
});
// Verify integration: utils validation prevents API call, hook shows error
expect(mockedAxios.post).not.toHaveBeenCalled();
expect(mockedApiClient.post).not.toHaveBeenCalled();
expect(result.current.errorMessage).toContain('Unsupported conversion format');
expect(result.current.isLoading).toBe(false);
expect(result.current.downloadUrl).toBe(null);
@@ -417,7 +444,7 @@ describe('Convert Tool Integration Tests', () => {
test('should handle multiple file uploads correctly', async () => {
const mockBlob = new Blob(['zip-content'], { type: 'application/zip' });
(mockedAxios.post as Mock).mockResolvedValueOnce({ data: mockBlob });
(mockedApiClient.post as Mock).mockResolvedValueOnce({ data: mockBlob });
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
@@ -458,7 +485,7 @@ describe('Convert Tool Integration Tests', () => {
});
// Verify both files were uploaded
const calls = (mockedAxios.post as Mock).mock.calls;
const calls = (mockedApiClient.post as Mock).mock.calls;
for (let i = 0; i < calls.length; i++) {
const formData = calls[i][1] as FormData;
@@ -506,7 +533,7 @@ describe('Convert Tool Integration Tests', () => {
await result.current.executeOperation(parameters, []);
});
expect(mockedAxios.post).not.toHaveBeenCalled();
expect(mockedApiClient.post).not.toHaveBeenCalled();
expect(result.current.errorMessage).toContain('noFileSelected');
});
});
@@ -514,7 +541,7 @@ describe('Convert Tool Integration Tests', () => {
describe('Error Boundary Integration', () => {
test('should handle corrupted file gracefully', async () => {
(mockedAxios.post as Mock).mockRejectedValueOnce({
(mockedApiClient.post as Mock).mockRejectedValueOnce({
response: {
status: 422,
data: 'Processing failed'
@@ -562,7 +589,7 @@ describe('Convert Tool Integration Tests', () => {
});
test('should handle backend service unavailable', async () => {
(mockedAxios.post as Mock).mockRejectedValueOnce({
(mockedApiClient.post as Mock).mockRejectedValueOnce({
response: {
status: 503,
data: 'Service unavailable'
@@ -614,7 +641,7 @@ describe('Convert Tool Integration Tests', () => {
test('should record operation in FileContext', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
(mockedAxios.post as Mock).mockResolvedValueOnce({
(mockedApiClient.post as Mock).mockResolvedValueOnce({
data: mockBlob,
status: 200,
headers: {
@@ -667,7 +694,7 @@ describe('Convert Tool Integration Tests', () => {
test('should clean up blob URLs on reset', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
(mockedAxios.post as Mock).mockResolvedValueOnce({
(mockedApiClient.post as Mock).mockResolvedValueOnce({
data: mockBlob,
status: 200,
headers: {
@@ -11,14 +11,41 @@ import { useConvertParameters } from '../../hooks/tools/convert/useConvertParame
import { FileContextProvider } from '../../contexts/FileContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n/config';
import axios from 'axios';
import { detectFileExtension } from '../../utils/fileUtils';
import { FIT_OPTIONS } from '../../constants/convertConstants';
import { createTestStirlingFile, createTestFilesWithId } from '../utils/testFileHelpers';
// Mock axios
vi.mock('axios');
const mockedAxios = vi.mocked(axios);
// Mock axios (for static methods like CancelToken, isCancel)
vi.mock('axios', () => ({
default: {
CancelToken: {
source: vi.fn(() => ({
token: 'mock-cancel-token',
cancel: vi.fn()
}))
},
isCancel: vi.fn(() => false),
}
}));
// Mock our apiClient service
vi.mock('../../services/apiClient', () => ({
default: {
post: vi.fn(),
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
response: {
use: vi.fn()
}
}
}
}));
// Import the mocked apiClient
import apiClient from '../../services/apiClient';
const mockedApiClient = vi.mocked(apiClient);
// Mock only essential services that are actually called by the tests
vi.mock('../../services/fileStorage', () => ({
@@ -61,7 +88,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
vi.clearAllMocks();
// Mock successful API response
(mockedAxios.post as Mock).mockResolvedValue({
(mockedApiClient.post as Mock).mockResolvedValue({
data: new Blob(['fake converted content'], { type: 'application/pdf' })
});
});
@@ -103,7 +130,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
responseType: 'blob'
});
});
@@ -139,7 +166,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
responseType: 'blob'
});
});
@@ -183,12 +210,12 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), {
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/img/pdf', expect.any(FormData), {
responseType: 'blob'
});
// Should send all files in single request
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
const files = formData.getAll('fileInput');
expect(files).toHaveLength(3);
});
@@ -229,7 +256,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/file/pdf', expect.any(FormData), {
responseType: 'blob'
});
});
@@ -269,12 +296,12 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), {
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/html/pdf', expect.any(FormData), {
responseType: 'blob'
});
// Should process files separately for web files
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
});
});
@@ -306,7 +333,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
expect(formData.get('zoom')).toBe('1.5');
});
@@ -340,7 +367,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
expect(formData.get('includeAttachments')).toBe('false');
expect(formData.get('maxAttachmentSizeMB')).toBe('20');
expect(formData.get('downloadHtml')).toBe('true');
@@ -374,9 +401,9 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
expect(formData.get('outputFormat')).toBe('pdfa');
expect(mockedAxios.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), {
expect(mockedApiClient.post).toHaveBeenCalledWith('/api/v1/convert/pdf/pdfa', expect.any(FormData), {
responseType: 'blob'
});
});
@@ -418,7 +445,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
);
});
const formData = (mockedAxios.post as Mock).mock.calls[0][1] as FormData;
const formData = (mockedApiClient.post as Mock).mock.calls[0][1] as FormData;
expect(formData.get('fitOption')).toBe(FIT_OPTIONS.FIT_PAGE);
expect(formData.get('colorType')).toBe('grayscale');
expect(formData.get('autoRotate')).toBe('false');
@@ -455,7 +482,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Should make separate API calls for each file
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
});
});
@@ -472,7 +499,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
});
// Mock one success, one failure
(mockedAxios.post as Mock)
(mockedApiClient.post as Mock)
.mockResolvedValueOnce({
data: new Blob(['converted1'], { type: 'application/pdf' })
})
@@ -498,7 +525,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
await waitFor(() => {
// Should have processed at least one file successfully
expect(operationResult.current.files.length).toBeGreaterThan(0);
expect(mockedAxios.post).toHaveBeenCalledTimes(2);
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
});
});
});
+197
View File
@@ -0,0 +1,197 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFileSelection } from "../contexts/FileContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddAttachmentsParameters } from "../hooks/tools/addAttachments/useAddAttachmentsParameters";
import { useAddAttachmentsOperation } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
import LocalIcon from "../components/shared/LocalIcon";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
// Removed FitText for two-line wrapping with clamping
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const params = useAddAttachmentsParameters();
const operation = useAddAttachmentsOperation();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-attachments");
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
const handleExecute = async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error: any) {
onError?.(error?.message || t("AddAttachmentsRequest.error.failed", "Add attachments operation failed"));
}
};
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
enum AddAttachmentsStep {
NONE = 'none',
ATTACHMENTS = 'attachments'
}
const accordion = useAccordionSteps<AddAttachmentsStep>({
noneValue: AddAttachmentsStep.NONE,
initialStep: AddAttachmentsStep.ATTACHMENTS,
stateConditions: {
hasFiles,
hasResults: false // Don't collapse when there are results for add attachments
},
afterResults: () => {
operation.resetResults();
onPreviewFile?.(null);
}
});
const getSteps = () => {
const steps: any[] = [];
// Step 1: Attachments Selection
steps.push({
title: t("AddAttachmentsRequest.attachments", "Select Attachments"),
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
isVisible: true,
content: (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
onChange={(e) => {
const files = Array.from(e.target.files || []);
// Append to existing attachments instead of replacing
const newAttachments = [...params.parameters.attachments, ...files];
params.updateParameter('attachments', newAttachments);
// Reset the input so the same file can be selected again
e.target.value = '';
}}
disabled={endpointLoading}
style={{ display: 'none' }}
id="attachments-input"
/>
<Button
size="xs"
color="blue"
component="label"
htmlFor="attachments-input"
disabled={endpointLoading}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
>
{params.parameters.attachments.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
: t("AddAttachmentsRequest.placeholder", "Choose files...")
}
</Button>
</Stack>
{params.parameters.attachments && params.parameters.attachments.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({params.parameters.attachments.length})
</Text>
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
<Stack gap="xs">
{params.parameters.attachments.map((file, index) => (
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
{/* Filename (two-line clamp, wraps, no icon on the left) */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 400,
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2 as any,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
style={{ flexShrink: 0 }}
onClick={() => {
const newAttachments = params.parameters.attachments.filter((_, i) => i !== index);
params.updateParameter('attachments', newAttachments);
}}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
),
});
return steps;
};
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasResults,
},
steps: getSteps(),
executeButton: {
text: t('AddAttachmentsRequest.submit', 'Add Attachments'),
isVisible: !hasResults,
loadingText: t('loading'),
onClick: handleExecute,
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: operation,
title: t('AddAttachmentsRequest.results.title', 'Attachment Results'),
onFileClick: (file) => onPreviewFile?.(file),
onUndo: async () => {
await operation.undoOperation();
onPreviewFile?.(null);
},
},
});
};
AddAttachments.tool = () => useAddAttachmentsOperation;
export default AddAttachments as ToolComponent;
+55
View File
@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
import { useExtractImagesParameters } from "../hooks/tools/extractImages/useExtractImagesParameters";
import { useExtractImagesOperation } from "../hooks/tools/extractImages/useExtractImagesOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const ExtractImages = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'extractImages',
useExtractImagesParameters,
useExtractImagesOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("extractImages.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: (
<ExtractImagesSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("extractImages.submit", "Extract Images"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("extractImages.title", "Extracted Images"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ExtractImages as ToolComponent;
+57
View File
@@ -0,0 +1,57 @@
import { useTranslation } from 'react-i18next';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
import { BaseToolProps, ToolComponent } from '../types/tool';
import { usePageLayoutParameters } from '../hooks/tools/pageLayout/usePageLayoutParameters';
import { usePageLayoutOperation } from '../hooks/tools/pageLayout/usePageLayoutOperation';
import PageLayoutSettings from '../components/tools/pageLayout/PageLayoutSettings';
const PageLayout = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'pageLayout',
usePageLayoutParameters,
usePageLayoutOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: 'Settings',
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: (
<PageLayoutSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t('pageLayout.submit', 'Create Layout'),
isVisible: !base.hasResults,
loadingText: t('loading'),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t('pageLayout.title', 'Multi Page Layout Results'),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default PageLayout as ToolComponent;
+49
View File
@@ -0,0 +1,49 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import RemoveAnnotationsSettings from "../components/tools/removeAnnotations/RemoveAnnotationsSettings";
import { useRemoveAnnotationsParameters } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsParameters";
import { useRemoveAnnotationsOperation } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const RemoveAnnotations = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'removeAnnotations',
useRemoveAnnotationsParameters,
useRemoveAnnotationsOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("removeAnnotations.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: <RemoveAnnotationsSettings />,
},
],
executeButton: {
text: t("removeAnnotations.submit", "Remove Annotations"),
isVisible: !base.hasResults,
loadingText: t("loading", "Processing..."),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("removeAnnotations.title", "Annotations Removed"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default RemoveAnnotations as ToolComponent;
+45
View File
@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { useRemoveImageParameters } from "../hooks/tools/removeImage/useRemoveImageParameters";
import { useRemoveImageOperation } from "../hooks/tools/removeImage/useRemoveImageOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
const RemoveImage = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'removeImage',
useRemoveImageParameters,
useRemoveImageOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [],
executeButton: {
text: t("removeImage.submit", "Remove Images"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("removeImage.results.title", "Remove Images Results"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
RemoveImage.tool = () => useRemoveImageOperation;
export default RemoveImage as ToolComponent;
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileSelection } from "../contexts/FileContext";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
import ReorganizePagesSettings from "../components/tools/reorganizePages/ReorganizePagesSettings";
import { useReorganizePagesParameters } from "../hooks/tools/reorganizePages/useReorganizePagesParameters";
import { useReorganizePagesOperation } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const params = useReorganizePagesParameters();
const operation = useReorganizePagesOperation();
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("rearrange-pages");
useEffect(() => {
operation.resetResults();
onPreviewFile?.(null);
}, [params.parameters]);
const handleExecute = async () => {
try {
await operation.executeOperation(params.parameters, selectedFiles);
if (operation.files && onComplete) {
onComplete(operation.files);
}
} catch (error: any) {
onError?.(error?.message || t("reorganizePages.error.failed", "Failed to reorganize pages"));
}
};
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
enum Step {
NONE = 'none',
SETTINGS = 'settings'
}
const accordion = useAccordionSteps<Step>({
noneValue: Step.NONE,
initialStep: Step.SETTINGS,
stateConditions: {
hasFiles,
hasResults
},
afterResults: () => {
operation.resetResults();
onPreviewFile?.(null);
}
});
const steps = [
{
title: t("reorganizePages.settings.title", "Settings"),
isCollapsed: accordion.getCollapsedState(Step.SETTINGS),
onCollapsedClick: () => accordion.handleStepToggle(Step.SETTINGS),
isVisible: true,
content: (
<ReorganizePagesSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
}
];
return createToolFlow({
files: {
selectedFiles,
isCollapsed: hasResults,
},
steps,
executeButton: {
text: t('reorganizePages.submit', 'Reorganize Pages'),
isVisible: !hasResults,
loadingText: t('loading'),
onClick: handleExecute,
disabled: !params.validateParameters() || !hasFiles || !endpointEnabled,
},
review: {
isVisible: hasResults,
operation: operation,
title: t('reorganizePages.results.title', 'Pages Reorganized'),
onFileClick: (file) => onPreviewFile?.(file),
onUndo: async () => {
await operation.undoOperation();
onPreviewFile?.(null);
},
},
});
};
(ReorganizePages as any).tool = () => useReorganizePagesOperation;
export default ReorganizePages as ToolComponent;
+58
View File
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
import { useReplaceColorParameters } from "../hooks/tools/replaceColor/useReplaceColorParameters";
import { useReplaceColorOperation } from "../hooks/tools/replaceColor/useReplaceColorOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useReplaceColorTips } from "../components/tooltips/useReplaceColorTips";
const ReplaceColor = (props: BaseToolProps) => {
const { t } = useTranslation();
const replaceColorTips = useReplaceColorTips();
const base = useBaseTool(
'replaceColor',
useReplaceColorParameters,
useReplaceColorOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("replaceColor.labels.settings", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: replaceColorTips,
content: (
<ReplaceColorSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("replace-color.submit", "Replace"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("replace-color.title", "Replace-Invert-Color"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ReplaceColor as ToolComponent;
+58
View File
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import { useScannerImageSplitParameters } from "../hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
import { useScannerImageSplitOperation } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import { useScannerImageSplitTips } from "../components/tooltips/useScannerImageSplitTips";
const ScannerImageSplit = (props: BaseToolProps) => {
const { t } = useTranslation();
const scannerImageSplitTips = useScannerImageSplitTips();
const base = useBaseTool(
'scannerImageSplit',
useScannerImageSplitParameters,
useScannerImageSplitOperation,
props
);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: "Settings",
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: scannerImageSplitTips,
content: (
<ScannerImageSplitSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t("scannerImageSplit.submit", "Extract Image Scans"),
isVisible: !base.hasResults,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t("scannerImageSplit.title", "Extracted Images"),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
});
};
export default ScannerImageSplit as ToolComponent;
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { useSignParameters } from "../hooks/tools/sign/useSignParameters";
import { useSignOperation } from "../hooks/tools/sign/useSignOperation";
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "../types/tool";
import SignSettings from "../components/tools/sign/SignSettings";
import { useNavigation } from "../contexts/NavigationContext";
import { useSignature } from "../contexts/SignatureContext";
import { useFileContext } from "../contexts/FileContext";
import { useViewer } from "../contexts/ViewerContext";
import { flattenSignatures } from "../utils/signatureFlattening";
const Sign = (props: BaseToolProps) => {
const { t } = useTranslation();
const { setWorkbench } = useNavigation();
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature();
const { consumeFiles, selectors } = useFileContext();
const { exportActions, getScrollState } = useViewer();
// Track which signature mode was active for reactivation after save
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
// Single handler that activates placement mode
const handleSignaturePlacement = useCallback(() => {
activateSignaturePlacementMode();
}, [activateSignaturePlacementMode]);
// Memoized callbacks for SignSettings to prevent infinite loops
const handleActivateDrawMode = useCallback(() => {
activeModeRef.current = 'draw';
activateDrawMode();
}, [activateDrawMode]);
const handleActivateSignaturePlacement = useCallback(() => {
activeModeRef.current = 'placement';
handleSignaturePlacement();
}, [handleSignaturePlacement]);
const base = useBaseTool(
'sign',
useSignParameters,
useSignOperation,
props
);
// Open viewer when files are selected
useEffect(() => {
if (base.selectedFiles.length > 0) {
setWorkbench('viewer');
}
}, [base.selectedFiles.length, setWorkbench]);
// Sync signature configuration with context
useEffect(() => {
setSignatureConfig(base.params.parameters);
}, [base.params.parameters, setSignatureConfig]);
// Save signed files to the system - apply signatures using EmbedPDF and replace original
const handleSaveToSystem = useCallback(async () => {
try {
// Get the original file
let originalFile = null;
if (base.selectedFiles.length > 0) {
originalFile = base.selectedFiles[0];
} else {
const allFileIds = selectors.getAllFileIds();
if (allFileIds.length > 0) {
const stirlingFile = selectors.getFile(allFileIds[0]);
if (stirlingFile) {
originalFile = stirlingFile;
}
}
}
if (!originalFile) {
console.error('No file available to replace');
return;
}
// Use the signature flattening utility
const success = await flattenSignatures({
signatureApiRef,
getImageData,
exportActions,
selectors,
consumeFiles,
originalFile,
getScrollState
});
if (success) {
console.log('✓ Signature flattening completed successfully');
// Mark signatures as applied
setSignaturesApplied(true);
// Force refresh the viewer to show the flattened PDF
setTimeout(() => {
// Navigate away from viewer and back to force reload
setWorkbench('fileEditor');
setTimeout(() => {
setWorkbench('viewer');
// Reactivate the signature mode that was active before save
if (activeModeRef.current === 'draw') {
activateDrawMode();
} else if (activeModeRef.current === 'placement') {
handleSignaturePlacement();
}
}, 100);
}, 200);
} else {
console.error('Signature flattening failed');
}
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode]);
const getSteps = () => {
const steps = [];
// Step 1: Signature Configuration - Always visible
steps.push({
title: t('sign.steps.configure', 'Configure Signature'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<SignSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
onActivateDrawMode={handleActivateDrawMode}
onActivateSignaturePlacement={handleActivateSignaturePlacement}
onDeactivateSignature={deactivateDrawMode}
onUpdateDrawSettings={updateDrawSettings}
onUndo={undo}
onRedo={redo}
onSave={handleSaveToSystem}
/>
),
});
return steps;
};
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.operation.files.length > 0,
},
steps: getSteps(),
review: {
isVisible: false, // Hide review section - save moved to configure section
operation: base.operation,
title: t('sign.results.title', 'Signature Results'),
onFileClick: base.handleThumbnailClick,
onUndo: () => {},
},
forceStepNumbers: true,
});
};
// Add the required static methods for automation
Sign.tool = () => useSignOperation;
Sign.getDefaultParameters = () => ({
signatureType: 'canvas',
reason: 'Document signing',
location: 'Digital',
signerName: '',
});
export default Sign as ToolComponent;
+2 -5
View File
@@ -11,8 +11,6 @@ export const TOOL_IDS = [
'changePermissions',
'watermark',
'sanitize',
'autoSplitPDF',
'autoSizeSplitPDF',
'split',
'merge',
'convert',
@@ -21,7 +19,7 @@ export const TOOL_IDS = [
'rotate',
'scannerImageSplit',
'editTableOfContents',
'fakeScan',
'scannerEffect',
'autoRename',
'pageLayout',
'scalePages',
@@ -44,12 +42,11 @@ export const TOOL_IDS = [
'addAttachments',
'changeMetadata',
'overlayPdfs',
'manageCertificates',
'getPdfInfo',
'validateSignature',
'read',
'automate',
'replaceColorPdf',
'replaceColor',
'showJS',
'devApi',
'devFolderScanning',
+309
View File
@@ -0,0 +1,309 @@
import { PDFDocument, rgb } from 'pdf-lib';
import { generateThumbnailWithMetadata } from './thumbnailUtils';
import { createProcessedFile } from '../contexts/file/fileActions';
import { createNewStirlingFileStub, createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
interface MinimalFileContextSelectors {
getAllFileIds: () => FileId[];
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
getFile: (id: FileId) => StirlingFile | undefined;
}
interface SignatureFlatteningOptions {
signatureApiRef: React.RefObject<SignatureAPI | null>;
getImageData: (id: string) => string | undefined;
exportActions?: {
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
selectors: MinimalFileContextSelectors;
consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise<FileId[]>;
originalFile?: StirlingFile;
getScrollState: () => { currentPage: number; totalPages: number };
}
export async function flattenSignatures(options: SignatureFlatteningOptions): Promise<boolean> {
const { signatureApiRef, getImageData, exportActions, selectors, consumeFiles, originalFile, getScrollState } = options;
try {
// Step 1: Extract all annotations from EmbedPDF before export
const allAnnotations: Array<{pageIndex: number, annotations: any[]}> = [];
if (signatureApiRef?.current) {
// Get actual page count from viewer
const scrollState = getScrollState();
const totalPages = scrollState.totalPages;
// Check only actual pages that exist in the document
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
try {
const pageAnnotations = await signatureApiRef.current.getPageAnnotations(pageIndex);
if (pageAnnotations && pageAnnotations.length > 0) {
// Filter to only include annotations added in this session
const sessionAnnotations = pageAnnotations.filter(annotation => {
// Check if this annotation has stored image data (indicates it was added this session)
const hasStoredImageData = annotation.id && getImageData(annotation.id);
// Also check if it has image data directly in the annotation (new signatures)
const hasDirectImageData = annotation.imageData || annotation.appearance ||
annotation.stampData || annotation.imageSrc ||
annotation.contents || annotation.data;
const isSessionAnnotation = hasStoredImageData || (hasDirectImageData && typeof hasDirectImageData === 'string' && hasDirectImageData.startsWith('data:image'));
return isSessionAnnotation;
});
if (sessionAnnotations.length > 0) {
allAnnotations.push({pageIndex, annotations: sessionAnnotations});
}
}
} catch (pageError) {
console.warn(`Error extracting annotations from page ${pageIndex + 1}:`, pageError);
}
}
}
console.log(`Total annotations found: ${allAnnotations.reduce((sum, page) => sum + page.annotations.length, 0)}`);
// Step 2: Delete ONLY session annotations from EmbedPDF before export (they'll be rendered manually)
// Leave old annotations alone - they will remain as annotations in the PDF
if (allAnnotations.length > 0 && signatureApiRef?.current) {
for (const pageData of allAnnotations) {
for (const annotation of pageData.annotations) {
try {
await signatureApiRef.current.deleteAnnotation(annotation.id, pageData.pageIndex);
} catch (deleteError) {
console.warn(`Failed to delete annotation ${annotation.id}:`, deleteError);
}
}
}
}
// Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations)
if (!exportActions) {
console.error('No export actions available');
return false;
}
const pdfArrayBuffer = await exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Try loading with more permissive PDF-lib options
// Convert ArrayBuffer to File
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the current file - try from originalFile first, then from all files
let currentFile = originalFile;
if (!currentFile) {
const allFileIds = selectors.getAllFileIds();
if (allFileIds.length > 0) {
const fileStub = selectors.getStirlingFileStub(allFileIds[0]);
const fileObject = selectors.getFile(allFileIds[0]);
if (fileStub && fileObject) {
currentFile = createStirlingFile(fileObject, allFileIds[0] as FileId);
}
}
}
if (!currentFile) {
console.error('No file available to replace');
return false;
}
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
// Step 4: Manually render extracted annotations onto the PDF using PDF-lib
if (allAnnotations.length > 0) {
try {
console.log('Manually rendering annotations onto PDF...');
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
// Try different loading options to handle problematic PDFs
let pdfDoc: PDFDocument;
try {
pdfDoc = await PDFDocument.load(pdfArrayBufferForFlattening, {
ignoreEncryption: true,
capNumbers: false,
throwOnInvalidObject: false
});
} catch {
console.warn('Failed to load with standard options, trying createProxy...');
try {
// Create a fresh PDF and copy pages instead of modifying
pdfDoc = await PDFDocument.create();
const sourcePdf = await PDFDocument.load(pdfArrayBufferForFlattening, {
ignoreEncryption: true,
throwOnInvalidObject: false
});
const pageIndices = sourcePdf.getPages().map((_, i) => i);
const copiedPages = await pdfDoc.copyPages(sourcePdf, pageIndices);
copiedPages.forEach(page => pdfDoc.addPage(page));
} catch (copyError) {
console.error('Failed to load PDF with any method:', copyError);
throw copyError;
}
}
const pages = pdfDoc.getPages();
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
if (pageIndex < pages.length) {
const page = pages[pageIndex];
const { height: pageHeight } = page.getSize();
for (const annotation of annotations) {
try {
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
if (rect) {
// Extract original annotation position and size
const originalX = rect.origin?.x || rect.x || rect.left || 0;
const originalY = rect.origin?.y || rect.y || rect.top || 0;
const width = rect.size?.width || rect.width || 100;
const height = rect.size?.height || rect.height || 50;
// Convert EmbedPDF coordinates to PDF-lib coordinates
const pdfX = originalX;
const pdfY = pageHeight - originalY - height;
// Try to get annotation image data
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
annotation.imageSrc || annotation.contents || annotation.data;
// If no image data found directly, try to get it from storage
if (!imageDataUrl && annotation.id) {
const storedImageData = getImageData(annotation.id);
if (storedImageData) {
imageDataUrl = storedImageData;
}
}
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
try {
// Convert data URL to bytes
const base64Data = imageDataUrl.split(',')[1];
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
// Embed image in PDF based on data URL type
let image;
if (imageDataUrl.includes('data:image/jpeg') || imageDataUrl.includes('data:image/jpg')) {
image = await pdfDoc.embedJpg(imageBytes);
} else if (imageDataUrl.includes('data:image/png')) {
image = await pdfDoc.embedPng(imageBytes);
} else {
image = await pdfDoc.embedPng(imageBytes);
}
// Draw image on page at annotation position
page.drawImage(image, {
x: pdfX,
y: pdfY,
width: width,
height: height,
});
} catch (imageError) {
console.error('Failed to render image annotation:', imageError);
}
} else if (annotation.content || annotation.text) {
// Handle text annotations
page.drawText(annotation.content || annotation.text, {
x: pdfX,
y: pdfY + height - 12, // Adjust for text baseline
size: 12,
color: rgb(0, 0, 0)
});
} else if (annotation.type === 14 || annotation.type === 15) {
// Handle ink annotations (drawn signatures)
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(0, 0, 0),
borderWidth: 2,
color: rgb(0.9, 0.9, 0.9), // Light gray background
opacity: 0.8
});
page.drawText('Drawn Signature', {
x: pdfX + 5,
y: pdfY + height / 2,
size: 10,
color: rgb(0, 0, 0)
});
} else {
// Handle other annotation types
page.drawRectangle({
x: pdfX,
y: pdfY,
width: width,
height: height,
borderColor: rgb(1, 0, 0),
borderWidth: 2,
color: rgb(1, 1, 0), // Yellow background
opacity: 0.5
});
}
}
} catch (annotationError) {
console.warn('Failed to render annotation:', annotationError);
}
}
}
}
// Save the PDF with rendered annotations
const flattenedPdfBytes = await pdfDoc.save({ useObjectStreams: false, addDefaultPage: false });
const arrayBuffer = new ArrayBuffer(flattenedPdfBytes.length);
const uint8View = new Uint8Array(arrayBuffer);
uint8View.set(flattenedPdfBytes);
signedFile = new File([arrayBuffer], currentFile.name, { type: 'application/pdf' });
} catch (renderError) {
console.error('Failed to manually render annotations:', renderError);
console.warn('Signatures may only show as annotations');
}
}
// Generate thumbnail and metadata for the signed file
const thumbnailResult = await generateThumbnailWithMetadata(signedFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Prepare input file data for replacement
const inputFileIds: FileId[] = [currentFile.fileId];
const record = selectors.getStirlingFileStub(currentFile.fileId);
if (!record) {
console.error('No file record found for:', currentFile.fileId);
return false;
}
// Create output stub and file
const outputStub = createNewStirlingFileStub(signedFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(signedFile, outputStub.id);
// Replace the original file with the signed version
await consumeFiles(inputFileIds, [outputStirlingFile], [outputStub]);
console.log('✓ Signature flattening completed successfully');
return true;
}
return false;
} catch (error) {
console.error('Error flattening signatures:', error);
return false;
}
}
+9 -10
View File
@@ -39,7 +39,6 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/remove-cert-sign': 'removeCertSign',
'/unlock-pdf-forms': 'unlockPDFForms',
'/validate-signature': 'validateSignature',
'/manage-certificates': 'manageCertificates',
// Content manipulation
'/sanitize': 'sanitize',
@@ -64,8 +63,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/booklet-imposition': 'bookletImposition',
// Splitting tools
'/auto-split-pdf': 'autoSplitPDF',
'/auto-size-split-pdf': 'autoSizeSplitPDF',
'/auto-split-pdf': 'split',
'/auto-size-split-pdf': 'split',
'/scanner-image-split': 'scannerImageSplit',
// Annotation and content removal
@@ -75,8 +74,8 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
// Image and visual tools
'/extract-images': 'extractImages',
'/adjust-contrast': 'adjustContrast',
'/fake-scan': 'fakeScan',
'/replace-color-pdf': 'replaceColorPdf',
'/fake-scan': 'scannerEffect',
'/replace-color-pdf': 'replaceColor',
// Metadata and info
'/change-metadata': 'changeMetadata',
@@ -116,13 +115,13 @@ export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
'/view-pdf': 'read',
'/get-info-on-pdf': 'getPdfInfo',
'/remove-image-pdf': 'removeImage',
'/replace-and-invert-color-pdf': 'replaceColorPdf',
'/replace-and-invert-color-pdf': 'replaceColor',
'/pipeline': 'automate',
'/extract-image-scans': 'scannerImageSplit',
'/show-javascript': 'showJS',
'/scanner-effect': 'fakeScan',
'/split-by-size-or-count': 'autoSizeSplitPDF',
'/scanner-effect': 'scannerEffect',
'/split-by-size-or-count': 'split',
'/overlay-pdf': 'overlayPdfs',
'/split-pdf-by-sections': 'autoSplitPDF',
'/split-pdf-by-chapters': 'autoSplitPDF',
'/split-pdf-by-sections': 'split',
'/split-pdf-by-chapters': 'split',
};