Compare commits

..
Author SHA1 Message Date
EthanHealy01 14ef08e8f4 stashing for now, this adds the ability to fetch the username if it exists 2026-03-25 20:41:37 +00:00
c46156f37f Bump/embed pdfv2.8.0 (#5921)
please merge #5919, alternatively, just push this and delete that PR
because this is a continuation of that.

This PR bumps the embed PDF version to 2.8.0 and also adds comments
functionaliy

---------

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-03-23 14:35:39 +00:00
Reece BrowneandGitHub 41945543e0 Fix save converted files (#5971)
Fix saving converted files on tauri
2026-03-23 13:51:40 +00:00
James BruntonandGitHub e5f6180dbe Remove cmd-r override for rotation because it interferes with refresh (#5981)
# Description of Changes
Currently, cmd-r is set to rotate the PDF in the viewer instead of
perform refresh in the browser. This is unintuitive and confusing for
Mac users, and for Windows users (who are less used to doing ctrl-r for
refresh) it only works some of the time, if the Viewer is active, so
removing the override is no great loss.
2026-03-23 13:26:10 +00:00
James BruntonandGitHub 57c810ab9a Add frontend developer guide describing the path alias architecture (#5964)
# Description of Changes
Add frontend developer guide describing the path alias architecture.
There's probably more needed in here which we should flesh out over
time, but this is a start.
2026-03-23 10:16:52 +00:00
briosandGitHub b012f18a40 fix(gradle): bump gradle jar version to 9.3.1-bin (#5938) 2026-03-20 12:00:01 +00:00
Anthony StirlingandGitHub 9e8606cab4 XSS for eml and others (#5967) 2026-03-20 11:55:23 +00:00
55bcb92810 Add explicit Save As button for desktop viewer (issue #5928) (#5959)
## Description

Adds an explicit **“Save As”** button to the desktop viewer so users can
always save a copy of the current PDF to a different location, even if
the original file already has a local path.

This complements the existing smart **Save/Download** behavior:
- The existing download button continues to either save back to the
original path (when available) or prompt for a path when needed.
- The new **Save As** button always opens a save dialog to choose a
location/name for a new copy.

## Changes

- **RightRail (viewer controls)**
- Added a new **Save As** action icon in the right rail settings
section.
  - The button:
- Uses `viewerContext.exportActions.saveAsCopy()` to get the current
viewer state as a PDF.
- Calls `downloadFile` without a `localPath`, ensuring the desktop app
shows a **Save As** dialog.
- Picks the first selected file (if any) or the first active file as the
source for the filename.
- **Desktop / Web behavior**
  - In the desktop app (Tauri), clicking **Save As**:
- Opens a native save dialog so the user can choose a different folder
and filename.
- Writes a new copy without changing the existing file’s `localFilePath`
or dirty state.
- In the web app, the button behaves like a standard download of a copy
(browser-controlled save dialog / download).

## Motivation

- Users often want to apply operations on a PDF while **keeping the
original unmodified**.
- The existing smart Save behavior chooses between Save and Save As
automatically, but there was no way to explicitly request **Save As**.
- This change gives desktop users a clear, dedicated **“Save As”**
control while preserving the current Save/Download behavior.

## Notes

- No backend changes.
- No changes to the existing Save / Download button behavior.
- The new button uses existing viewer export and download utilities,
minimizing new logic.

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-03-20 09:32:24 +00:00
Aarón Rosa DíazandGitHub a7f2abcb22 Update Spanish translation (translation.toml) (#5965) 2026-03-19 17:15:42 +00:00
49 changed files with 2386 additions and 829 deletions
@@ -159,7 +159,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Run Gradle Command
run: |
+5 -5
View File
@@ -80,7 +80,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
@@ -165,7 +165,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Generate OpenAPI documentation
@@ -247,7 +247,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: check the licenses for compatibility
@@ -320,7 +320,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Set up Docker Buildx
@@ -427,7 +427,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
cache-disabled: true
- name: Build application
@@ -339,7 +339,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Check licenses and generate report
id: license-check
+3 -3
View File
@@ -61,7 +61,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Get version number
id: versionNumber
@@ -134,7 +134,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -214,7 +214,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build Java backend with JLink
working-directory: ./
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build with Gradle
run: ./gradlew build
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Set up Docker Buildx
id: buildx
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+1 -1
View File
@@ -126,7 +126,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build Java backend with JLink
working-directory: ./
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 8.14
gradle-version: 9.3.1
- name: Build with Gradle
run: ./gradlew build
+2
View File
@@ -55,6 +55,8 @@ Development for the AI engine happens in the `engine/` folder. It's built with L
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, see [frontend/DeveloperGuide.md](frontend/DeveloperGuide.md).
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
@@ -226,10 +226,11 @@ public class SsrfProtectionService {
}
private boolean isPrivateIPv4Range(String ip) {
// Includes RFC1918, loopback, link-local, and unspecified addresses
// Includes RFC1918, RFC6598, loopback, link-local, and unspecified addresses
return ip.startsWith("10.")
|| ip.startsWith("192.168.")
|| (ip.startsWith("172.") && isInRange172(ip))
|| (ip.startsWith("100.") && isInRange100(ip))
|| ip.startsWith("169.254.")
|| ip.startsWith("127.")
|| "0.0.0.0".equals(ip);
@@ -247,6 +248,18 @@ public class SsrfProtectionService {
return false;
}
private boolean isInRange100(String ip) {
String[] parts = ip.split("\\.");
if (parts.length >= 2) {
try {
int secondOctet = Integer.parseInt(parts[1]);
return secondOctet >= 64 && secondOctet <= 127;
} catch (NumberFormatException e) {
}
}
return false;
}
private boolean isCloudMetadataAddress(String ip) {
String normalizedIp = normalizeIpv4MappedAddress(ip);
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
@@ -13,11 +13,17 @@ public class EmlToPdf {
public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request)
throws IOException {
return convertEmlToHtml(emlBytes, request, null);
}
public static String convertEmlToHtml(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
EmlParser.EmailContent emailContent =
EmlParser.extractEmailContent(emlBytes, request, null);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null);
EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer);
return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, customHtmlSanitizer);
}
public static byte[] convertEmlToPdf(
@@ -81,7 +81,8 @@ public class ConvertEmlToPDF {
if (request.isDownloadHtml()) {
try {
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
String htmlContent =
EmlToPdf.convertEmlToHtml(fileBytes, request, customHtmlSanitizer);
log.info("Successfully converted email to HTML: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
htmlContent.getBytes(StandardCharsets.UTF_8),
@@ -230,6 +230,16 @@ public class ConfigController {
}
configData.put("isNewUser", isNewUser);
String username = null;
if (userService != null) {
try {
username = userService.getCurrentUsername();
} catch (Exception e) {
// Not authenticated or security disabled
}
}
configData.put("username", username);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -488,14 +488,14 @@ public class DatabaseService implements DatabaseServiceInterface {
private void executeDatabaseScript(Path scriptPath) {
if (isH2Database()) {
// Validate SQL content BEFORE execution to prevent injection attacks
validateSqlContent(scriptPath);
if (!verifyBackup(scriptPath)) {
log.error("Backup verification failed for: {}", scriptPath);
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
}
// Validate SQL content before execution to prevent injection attacks
validateSqlContent(scriptPath);
String query = "RUNSCRIPT from ?;";
try (Connection conn = dataSource.getConnection();
+68
View File
@@ -0,0 +1,68 @@
# Frontend Developer Guide
This document is a guide to the main frontend architectural rules in Stirling-PDF.
## Mode-Specific Code
There are several different builds of the frontend, each with their own mode-specific code.
The frontend uses [TypeScript Path Aliases](https://www.typescriptlang.org/tsconfig/#paths) to ensure that only relevant code for the configured app version will be present in the build.
Refer to the various `tsconfig.*.json` files to see the specific path alias order.
The vast majority of the code is in the `src/core` folder, which is the open-source app.
Other builds, such as the desktop app, use `src/core` as the base layer, and then override various files to change behaviour.
If an import is `from '@app/a/b'`, this will refer to `src/core/a/b.ts` in the core build of the app, but may refer to `src/desktop/a/b.ts` in the desktop app if that file exists.
It is important to try to minimise the amount of overridden code in the app.
Often, just one function needs to behave differently in a specific mode.
For example:
```ts
// core/file1.ts
function f1() { /* ... */ }
function f2() { /* ... */ } // Needs to be overridden in desktop
function f3() { /* ... */ }
```
In cases like this, instead of duplicating the entire file, create a new extension module for the core app and override _that_ in the desktop app.
```ts
// core/file1.ts
import { f2 } from '@app/file1Extensions';
function f1() { /* ... */ }
function f3() { /* ... */ }
```
```ts
// core/file1Extensions.ts
export function f2() { /* ... */ } // Original core implementation
```
```ts
// desktop/file1Extensions.ts
export function f2() { /* ... */ } // Custom desktop implementation
```
Building with this pattern minimises the duplicated code in the system and greatly reduces the chances that changing the core app will break the desktop app.
In general, all imports for app code should come via `@app` because it allows for other builds of the app to override behaviour if necessary.
The only time that it is beneficial to import via a specific folder (e.g. `@core`) is when you want to reduce duplication **in the file you are overriding**. For example:
```ts
// core/file2.ts
export interface MyProps {
// Lots of properties that we don't want to duplicate
}
export function f1(props: MyProps) { /* ... */ } // Original core implementation
```
```ts
// desktop/file2.ts
import { type MyProps } from '@core/file2';
export { type MyProps }; // Re-export so anything importing file2 can still access MyProps
export function f1(props: MyProps) { /* ... */ } // Custom desktop implementation
```
+212 -183
View File
@@ -12,29 +12,29 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.7.0",
"@embedpdf/engines": "^2.7.0",
"@embedpdf/models": "^2.7.0",
"@embedpdf/plugin-annotation": "^2.7.0",
"@embedpdf/plugin-attachment": "^2.7.0",
"@embedpdf/plugin-bookmark": "^2.7.0",
"@embedpdf/plugin-document-manager": "^2.7.0",
"@embedpdf/plugin-export": "^2.7.0",
"@embedpdf/plugin-history": "^2.7.0",
"@embedpdf/plugin-interaction-manager": "^2.7.0",
"@embedpdf/plugin-pan": "^2.7.0",
"@embedpdf/plugin-print": "^2.7.0",
"@embedpdf/plugin-redaction": "^2.7.0",
"@embedpdf/plugin-render": "^2.7.0",
"@embedpdf/plugin-rotate": "^2.7.0",
"@embedpdf/plugin-scroll": "^2.7.0",
"@embedpdf/plugin-search": "^2.7.0",
"@embedpdf/plugin-selection": "^2.7.0",
"@embedpdf/plugin-spread": "^2.7.0",
"@embedpdf/plugin-thumbnail": "^2.7.0",
"@embedpdf/plugin-tiling": "^2.7.0",
"@embedpdf/plugin-viewport": "^2.7.0",
"@embedpdf/plugin-zoom": "^2.7.0",
"@embedpdf/core": "^2.8.0",
"@embedpdf/engines": "^2.8.0",
"@embedpdf/models": "^2.8.0",
"@embedpdf/plugin-annotation": "^2.8.0",
"@embedpdf/plugin-attachment": "^2.8.0",
"@embedpdf/plugin-bookmark": "^2.8.0",
"@embedpdf/plugin-document-manager": "^2.8.0",
"@embedpdf/plugin-export": "^2.8.0",
"@embedpdf/plugin-history": "^2.8.0",
"@embedpdf/plugin-interaction-manager": "^2.8.0",
"@embedpdf/plugin-pan": "^2.8.0",
"@embedpdf/plugin-print": "^2.8.0",
"@embedpdf/plugin-redaction": "^2.8.0",
"@embedpdf/plugin-render": "^2.8.0",
"@embedpdf/plugin-rotate": "^2.8.0",
"@embedpdf/plugin-scroll": "^2.8.0",
"@embedpdf/plugin-search": "^2.8.0",
"@embedpdf/plugin-selection": "^2.8.0",
"@embedpdf/plugin-spread": "^2.8.0",
"@embedpdf/plugin-thumbnail": "^2.8.0",
"@embedpdf/plugin-tiling": "^2.8.0",
"@embedpdf/plugin-viewport": "^2.8.0",
"@embedpdf/plugin-zoom": "^2.8.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -115,7 +115,7 @@
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"puppeteer": "^24.25.0",
"tsx": "^4.19.4",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
@@ -476,6 +476,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -516,6 +517,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -574,13 +576,14 @@
}
},
"node_modules/@embedpdf/core": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.7.0.tgz",
"integrity": "sha512-dJ9pCWXVJxh6uSJP4sKuJP4v67+6Vlmw4WqJcv+CKJSUPdX9LhOSTIgTjwKLZ5zXo0c3DihNvrUupovb/DrhgQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.8.0.tgz",
"integrity": "sha512-ui0HR4fl7ndiGPw40kMBxXCO9gZHctV1u3Q+/XTd34ONYJ+Pa2LoWNVW2IuPDK7PgKzABPT2axntowlVLPP10g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "2.7.0",
"@embedpdf/models": "2.7.0"
"@embedpdf/engines": "2.8.0",
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -591,9 +594,9 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.7.0.tgz",
"integrity": "sha512-9BW/0f1EhMtklOXzpq+TtAELpXrvWcgCioFX9tSvPXBwWw/xva9c3UFx8NKehfrAkmcLGNz2c2Kqe8hDlKp9Rw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.8.0.tgz",
"integrity": "sha512-s749nppKxOcgvFraySKrwtiCt2VMXFe8TFuZUV5R7z8TtMagt6o5NOk6VsdvIpggUYxIsiKhLkFvAqvkNgcjng==",
"license": "MIT",
"dependencies": {
"@embedpdf/fonts-arabic": "1.0.0",
@@ -603,8 +606,8 @@
"@embedpdf/fonts-latin": "1.0.0",
"@embedpdf/fonts-sc": "1.0.0",
"@embedpdf/fonts-tc": "1.0.0",
"@embedpdf/models": "2.7.0",
"@embedpdf/pdfium": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/pdfium": "2.8.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -657,31 +660,32 @@
"license": "OFL-1.1"
},
"node_modules/@embedpdf/models": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.7.0.tgz",
"integrity": "sha512-SRtDqYUn6Qw677ECIyipg0qqZR0+iLUU1mGI/X70ad4/qhw+TAOF5/aWddoFdPAw5XviEYVw5zR0oTz+MLBUbw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.8.0.tgz",
"integrity": "sha512-kk3Fm8exMmEX9Ce7VQePybmo04NQGdpsO3FsX1YOQqHpLVBk7tiTeOdetjBqI+YhQ2zWLa2naNKSOSGGzYLyxA==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.7.0.tgz",
"integrity": "sha512-PW6JnbBERvRKVrBzeCH/iGorQ8OTTRwr8rciyv2Kstqf3IdIFHjjNWvl6exK1/3K5wbbxYnHSNpRotl0OcsFJA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.8.0.tgz",
"integrity": "sha512-RlNLRNboF1Y6fNDy4sJ/a/FEYxATZyeM+n25r3KZJjG+RaM6bxBWXvWlFlGBU5Vx2eqQ5AzDAmIE9cn1agFmqA==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.7.0.tgz",
"integrity": "sha512-bkn9+91XfcFoKgwZ0kiJLbnvWNyrorhcbX5grsvUw2gJ8mL0RtwHSnCjZVT3WOPleQVwnNkBZUHefJhNT+LEKw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.8.0.tgz",
"integrity": "sha512-h31dT0pvQjFSwsBLytL4BBLf3WDdz9kmAYNKR10filikge7MpgTzgVYFD0C6AOyy2qK1Y/vqworCyh+emVD5aA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0",
"@embedpdf/utils": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/utils": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-history": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/plugin-selection": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-history": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"@embedpdf/plugin-selection": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -690,15 +694,15 @@
}
},
"node_modules/@embedpdf/plugin-attachment": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.7.0.tgz",
"integrity": "sha512-APH9Y/P4KyawckS68bouQ5aGsmQurgkdBG0oRq9kirDM+yIVGonS2P+gzwu9otgNKve880S5mBImC3FndJL4Tg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.8.0.tgz",
"integrity": "sha512-g2jCwjhQsij9zz2JOxZJkIeLTAUxiBKsFh6K4hcsG45ougw/mI3WCw1f+bZlAPkZuOWPo2/nthsHb+wAlrcykQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -707,15 +711,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.7.0.tgz",
"integrity": "sha512-um+naqyEAhBp4uFIis6K+V0t3dY4Dgry/vW17SwK9lFyMPQNCUT8PZMb7ysvj88vBXaDbzqCbtnlV6AyBEqxEQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.8.0.tgz",
"integrity": "sha512-ab49e17amEshweobU2GbtDEuRDHj89vRYiahkRq9nU1ACI16JIyh4t4fE/m/ucL9YhMMXldjwdtTGHceV566Jg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -724,15 +728,15 @@
}
},
"node_modules/@embedpdf/plugin-document-manager": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.7.0.tgz",
"integrity": "sha512-eKM0MEUCPZmNkOkz5n6QWBcKN7m1c3RBZlfXB7faHBaScPyO3f+xgfipAht86UvfYwxT9BWk9ozbAtm2UPBHcQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.8.0.tgz",
"integrity": "sha512-SS+IKJ2+rk4dHM3PvQQrvnfNdb5oOF1INR5r2w0MKo22C9WPD8Ncg1Jak/mCrGbSrJemW3pkIrKZhoREQtSrbA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -741,15 +745,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.7.0.tgz",
"integrity": "sha512-wlP/ZdWPLCnJpsQWNCyZAlicvNiapbo7Dc95HMWHg2YgmkCjDe26z4C1QoubUanNSerGWJCK0qKXxJ/PVkJSnw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.8.0.tgz",
"integrity": "sha512-AvqfyhB58HLoZMKyXLLT+1ebE7wrMEnIAjr+OqEuaNnekxvb8atD8EBByzgtXLiNLStlxFn5g1CKyQThpw4Pcw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -758,15 +762,16 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.7.0.tgz",
"integrity": "sha512-k2c5M2Nwey+j6EtZyf5Sw+cuyNT4MTWdqPQGR9NbmZUiNnkSrg/TmPWZCyaXZKGRjLmoJocc0qRdyaZnaaTpqw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.8.0.tgz",
"integrity": "sha512-S6TO7DqMqVtBYsztgvPvq8BOJTMl8rWdGjVMuoxD93HZdSgogoculwCATrJGor/BC+X6Vmtaqg6NJWSdIAeBEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -775,15 +780,16 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.7.0.tgz",
"integrity": "sha512-IfJiugr2k9VD8/gryMb22wC5yk4EQwSS3/Co0EdOhzkmkGSgUHA/KcG83WBu8wPEtj5Gwy473xe41fYKsGMlsA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.8.0.tgz",
"integrity": "sha512-xdRTAp1YiXWm+3WVqIN8dkRT3I/dHTumLJy5Kvt7lc1W2XM3M5bfCk5eTTcjY1DPv2buyt44i/4XNWoXAgBXDg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -792,17 +798,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.7.0.tgz",
"integrity": "sha512-/jUyM4K42gd7eoVsKFDSfN4t8CHIf6ZX7kU4BHBBFvOcNB7bToOvibPiznC4oPrHmOuzcs4e+VOfu+yHKn6DrA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.8.0.tgz",
"integrity": "sha512-EFqTEHk9E7AMRwguRO24jRl0J/5+pG07wlAm5U2rB0LDk30oAqVklFuDygaALdi8ZRBdkWOfD3Wl3gurQvfwAA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -811,15 +817,15 @@
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.7.0.tgz",
"integrity": "sha512-sQqRSCN+r59EjKibQ3yMt1lKyHMxO1UqhZ6iI+w5hUBm9yTIHvkQRTVbawbkCfty8QNldjMQEwN3iYkf8LNHsg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.8.0.tgz",
"integrity": "sha512-PUdw2/1GwbewYrxVgsb+3lkYyYYcLU5qpazrBZjjx0v+SEpb2SMQOOPNUILeJGKbYUlxSz3qv/loFwqzALkUQA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
@@ -828,20 +834,20 @@
}
},
"node_modules/@embedpdf/plugin-redaction": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.7.0.tgz",
"integrity": "sha512-N6LD9k35ZCkpHPDW5I6pW6g/YTDXGuNp/kWTHU4bcRGfTwinu6i0PaFJQKus1t1pkVprhmjJgCXbkM2xmIKXAg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.8.0.tgz",
"integrity": "sha512-pIDFNd9rX7cwrhY6rFCBa5MTnGdLZHX7magToqna/Ffs1KEr0CfNF8jIXG0/E6KB6SsppbHwC7AkwRVWz8NoHg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0",
"@embedpdf/utils": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/utils": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-annotation": "2.7.0",
"@embedpdf/plugin-history": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/plugin-selection": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-annotation": "2.8.0",
"@embedpdf/plugin-history": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"@embedpdf/plugin-selection": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -850,15 +856,16 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.7.0.tgz",
"integrity": "sha512-qJcBxl2Kgqbw/Yb4qiX3qNsBKmzI8ijAKix3PoYL+nlhX6iFbcCkUbJgF0rPCBbhIy59Bpp7eJL+RyY5DhQvUg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.8.0.tgz",
"integrity": "sha512-jVGSuyg366LmFzbDpqszLbu3G6VOfv1u46D1C0ph6pL3jisTlRswRosaXS4eVE/fAYTryBrI0olCpVYYct4bQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -867,15 +874,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.7.0.tgz",
"integrity": "sha512-ggqhQzpJB7ydfx9fDcuUrlGS9wxfsLG+rQwf1MMmUMMdEK1NPsHw5abSuQBerAm1vrpZ3Dyz0v2TxPVejvIhjA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.8.0.tgz",
"integrity": "sha512-lWATWEwhkBW77dJaKXlSJbqEMLTSy1lOhn0kDAZ5eyPZRo+7UWe7LHuT5HWzzRlbY9rpvnyixCT6Zp9rKT+WnQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -884,16 +891,17 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.7.0.tgz",
"integrity": "sha512-uEs5XdHZ3XqH3OWnM8E2eHen+7+MxK9SrlBnfM8ZcPkzHR9n7hT/Aqy05BiLKxKem585HvtbRgFzNQZqmNIGHw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.8.0.tgz",
"integrity": "sha512-l6hFH6lsAI+07ZGuOwbC8qcRNdYzWSIfRszjt9UmhKZbvXLEp6YJeS/XOUC/37Kqi30tsmLDyZhMW1AooGAr1A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -902,15 +910,15 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.7.0.tgz",
"integrity": "sha512-TeUMvlp6j4NXkkbPURSkg7fd6l/X8wUnN+ImFiM01VM+YBLQWTPXcP7AAyFzoI5iAh2m5+ZZEB/uGb5qm3Vamg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.8.0.tgz",
"integrity": "sha512-HZ7munHdAF2pJ1cT02yc5I0Xcr4b4CQ8GkhD4qhTpZK0yga/tQiDY2Jjstygv7XiZwHIDKvqOuQsQmOXDsEemw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -919,17 +927,18 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.7.0.tgz",
"integrity": "sha512-MhTUHZ5jBh1jQU66xrtkx2jEtcreCfHy4zpmMY7iCoZtWUxrML+zf4jYNrR/ZP93O4i5HNBSnJNTG+RmRJ5KPw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.8.0.tgz",
"integrity": "sha512-DcPyOp2WKoVYVpbZIP5t+JsEmCL9Y7bxe8PmiEBtlm1lustuUezdWyA2G4wsGCR4I/5uNlY85qBGCBhL195sSA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0",
"@embedpdf/utils": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/utils": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -938,15 +947,15 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.7.0.tgz",
"integrity": "sha512-FMW6nXlyZhjQKrj8l4gduu0+IWI/H8Sda9iiKKYOGLrDWcIsSo3Gt3mSJgUsjUxA2YS+l0m48VeuSESa7Ab8oA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.8.0.tgz",
"integrity": "sha512-0Ld5HERaG8cKyWW7ktojG3FK6angkzYnBnw7Rnqf4cQuNdO7nKSTslyxkrLzF3vvmKrB//2ghm/pRVkgci4BOw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -955,16 +964,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.7.0.tgz",
"integrity": "sha512-ZnXt9ebtExFQUMUbWApUK8yC1eXYjDMEzS9631QpbLm0BhW9jYu9cs6U37Sww+zhYi7IFih4zb38lgAsbDgOcA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.8.0.tgz",
"integrity": "sha512-Dq/Cqsn4GClRXNWerqlXNH8WDWK/TtHxwq6yr2kqgXPo1FyREto4I7GfdO1hygTu0Dl6HK2auEVHBVC8QT53fw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-render": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-render": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -973,18 +982,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.7.0.tgz",
"integrity": "sha512-/Cd7dR0kA5/Y1gykYLYS3gecStycc4m5Jjm+a0mFzO/7yQ6BLb0g4zkM95YloBAsFoP5GPXKKhBw3pi32pTObw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.8.0.tgz",
"integrity": "sha512-gPe5mG6hyyruki1eSuQYD2KDbo0Z0TxzSk8TcHIdEYYF6NlI2OghuO8Vczz5WLU8clX8xzn8c/c+tQMHEXfbZA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-render": "2.7.0",
"@embedpdf/plugin-scroll": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-render": "2.8.0",
"@embedpdf/plugin-scroll": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -993,15 +1002,16 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.7.0.tgz",
"integrity": "sha512-nVDoU86CgHme7f2r7OFhziqHz1Yg8vdE2K+bXzRCmtZaubH+qVo5C+77at9brYCSinA67znQn1yy6zbfxs4FwA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.8.0.tgz",
"integrity": "sha512-E16hc4yPA54XQGHp0Dy3OYyE8ilBaJE7LJirVmha4kMkP7XBu6xHNOJrXtq4GsZmdLQkf+x8ie2DDbWS+tcwnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1010,17 +1020,17 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.7.0.tgz",
"integrity": "sha512-5u2cQcahHkbOfa5Ug5hxXwtIOz4TzrQSgvmXvO7+jV0jXKOp1vFVB9tU26SKwwwW9OL6e4L/P6roKJRPbOnWyw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.8.0.tgz",
"integrity": "sha512-HepSQ7NFYhMsQsgfnC/G3b3LOYtNCWkCME0C0jBLrEYsgqsWozf97jIgzfOAeJsugyjQmojpgLUNt3xE9CtG6Q==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-scroll": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-scroll": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1029,9 +1039,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.7.0.tgz",
"integrity": "sha512-QtHOChToxkemBbJSwBTbgUqQbUO+obRj5HBaA/8KGv1ESCjtrdwjfAHMCBYdRuyvMVvrQGe1bHuSbKpb2JVS5w==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.8.0.tgz",
"integrity": "sha512-mt3DiQ8pnPk95q0zv7dXfN+y5fzJT2WtXyST8ziYEgmfhz0l2HT/MHCAwLzB3Whlnd2BdHThLfJyG1UD6pC84g==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
@@ -1099,6 +1109,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1142,6 +1153,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2000,6 +2012,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.15.tgz",
"integrity": "sha512-wBn/GogB4x7a2Uj7Ztt3amRaApjED+9XqfE4wyCLh88R7KV55k9vnTdCx+irI/GLOOu9tXNUGm3a4t5sTajwkQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2050,6 +2063,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.15.tgz",
"integrity": "sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2126,6 +2140,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.8.tgz",
"integrity": "sha512-QKd1RhDXE1hf2sQDNayA9ic9jGkEgvZOf0tTkJxlBPG8ns8aS4rS8WwYURw2x5y3739p0HauUXX9WbH7UufFLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/core-downloads-tracker": "^7.3.8",
@@ -2573,6 +2588,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -3488,6 +3504,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3577,7 +3594,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4369,6 +4385,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4962,6 +4979,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4972,6 +4990,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -5052,6 +5071,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5501,7 +5521,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.29"
}
@@ -5511,7 +5530,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.29",
"@vue/shared": "3.5.29"
@@ -5522,7 +5540,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.29",
"@vue/runtime-core": "3.5.29",
@@ -5535,7 +5552,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.29",
"@vue/shared": "3.5.29"
@@ -5562,6 +5578,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5813,7 +5830,6 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -6100,6 +6116,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7011,6 +7028,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -7473,15 +7491,15 @@
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/devtools-protocol": {
"version": "0.0.1566079",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz",
"integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7869,6 +7887,7 @@
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -7978,8 +7997,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "11.1.1",
@@ -8044,7 +8062,6 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz",
"integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8866,6 +8883,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -9146,7 +9164,6 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9322,6 +9339,7 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -9883,8 +9901,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -10904,6 +10921,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11164,6 +11182,7 @@
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.354.0.tgz",
"integrity": "sha512-qrpToz7mN1PmEfo+Ob4Z8euX4z2p17LA0EAtFeyod3IVnlwnu+Ybea/oxVsPiq5YAPo+p5z73FcjF2yEJ7oZnA==",
"license": "SEE LICENSE IN LICENSE",
"peer": true,
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
@@ -11185,6 +11204,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -11559,6 +11579,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11568,6 +11589,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11659,7 +11681,8 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-number-format": {
"version": "5.4.4",
@@ -11676,6 +11699,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -12057,7 +12081,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -12907,7 +12932,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13235,6 +13259,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13428,6 +13453,7 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -13495,6 +13521,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13745,6 +13772,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -13919,6 +13947,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13932,6 +13961,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -14442,8 +14472,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
+25 -25
View File
@@ -8,29 +8,29 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.7.0",
"@embedpdf/engines": "^2.7.0",
"@embedpdf/models": "^2.7.0",
"@embedpdf/plugin-annotation": "^2.7.0",
"@embedpdf/plugin-attachment": "^2.7.0",
"@embedpdf/plugin-bookmark": "^2.7.0",
"@embedpdf/plugin-document-manager": "^2.7.0",
"@embedpdf/plugin-export": "^2.7.0",
"@embedpdf/plugin-history": "^2.7.0",
"@embedpdf/plugin-interaction-manager": "^2.7.0",
"@embedpdf/plugin-pan": "^2.7.0",
"@embedpdf/plugin-print": "^2.7.0",
"@embedpdf/plugin-redaction": "^2.7.0",
"@embedpdf/plugin-render": "^2.7.0",
"@embedpdf/plugin-rotate": "^2.7.0",
"@embedpdf/plugin-scroll": "^2.7.0",
"@embedpdf/plugin-search": "^2.7.0",
"@embedpdf/plugin-selection": "^2.7.0",
"@embedpdf/plugin-spread": "^2.7.0",
"@embedpdf/plugin-thumbnail": "^2.7.0",
"@embedpdf/plugin-tiling": "^2.7.0",
"@embedpdf/plugin-viewport": "^2.7.0",
"@embedpdf/plugin-zoom": "^2.7.0",
"@embedpdf/core": "^2.8.0",
"@embedpdf/engines": "^2.8.0",
"@embedpdf/models": "^2.8.0",
"@embedpdf/plugin-annotation": "^2.8.0",
"@embedpdf/plugin-attachment": "^2.8.0",
"@embedpdf/plugin-bookmark": "^2.8.0",
"@embedpdf/plugin-document-manager": "^2.8.0",
"@embedpdf/plugin-export": "^2.8.0",
"@embedpdf/plugin-history": "^2.8.0",
"@embedpdf/plugin-interaction-manager": "^2.8.0",
"@embedpdf/plugin-pan": "^2.8.0",
"@embedpdf/plugin-print": "^2.8.0",
"@embedpdf/plugin-redaction": "^2.8.0",
"@embedpdf/plugin-render": "^2.8.0",
"@embedpdf/plugin-rotate": "^2.8.0",
"@embedpdf/plugin-scroll": "^2.8.0",
"@embedpdf/plugin-search": "^2.8.0",
"@embedpdf/plugin-selection": "^2.8.0",
"@embedpdf/plugin-spread": "^2.8.0",
"@embedpdf/plugin-thumbnail": "^2.8.0",
"@embedpdf/plugin-tiling": "^2.8.0",
"@embedpdf/plugin-viewport": "^2.8.0",
"@embedpdf/plugin-zoom": "^2.8.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -40,6 +40,7 @@
"@mantine/hooks": "^8.3.1",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@posthog/react": "^1.8.2",
"@reactour/tour": "^3.8.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
@@ -63,7 +64,6 @@
"license-report": "^6.8.0",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"@posthog/react": "^1.8.2",
"posthog-js": "^1.268.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
@@ -174,8 +174,8 @@
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"puppeteer": "^24.25.0",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"tsx": "^4.19.4",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vite-plugin-static-copy": "^3.1.4",
+35 -1
View File
@@ -1503,8 +1503,9 @@ editSelected = "Edit Annotation"
editSquare = "Edit Square"
editStampHint = "To change the image, delete this stamp and add a new one."
editSwitchToSelect = "Switch to Select & Edit to edit this annotation."
editText = "Edit Text Box"
editText = "Edit"
editTextMarkup = "Edit Text Markup"
annotationStyle = "Annotation style"
ellipse = "Ellipse"
exit = "Exit annotation mode"
fillColor = "Fill Colour"
@@ -1515,13 +1516,19 @@ highlight = "Highlight"
imagePreview = "Preview"
inkHighlighter = "Freehand Highlighter"
line = "Line"
lineArrow = "Arrow"
noBackground = "No background"
note = "Note"
comment = "Comment"
comments = "Comments"
insertText = "Insert Text"
replaceText = "Replace Text"
noteIcon = "Note Icon"
notesStamps = "Notes & Stamps"
opacity = "Opacity"
pen = "Pen"
polygon = "Polygon"
polyline = "Polyline"
properties = "Properties"
rectangle = "Rectangle"
redo = "Redo"
@@ -4463,6 +4470,7 @@ rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar"
toggleBookmarks = "Toggle Bookmarks"
toggleComments = "Comments"
print = "Print PDF"
ruler = "Ruler / Measure"
readAloud = "Read Aloud"
@@ -4475,6 +4483,7 @@ exitRedaction = "Exit Redaction Mode"
save = "Save"
downloadAll = "Download All"
saveAll = "Save All"
saveAs = "Save As"
[textAlign]
left = "Left"
@@ -6913,6 +6922,31 @@ loading = "Loading attachments..."
empty = "No attachments in this document"
noMatch = "No attachments match your search"
[viewer.comments]
title = "Comments"
hint = "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page."
placeholder = "Type your comment..."
pageLabel = "Page {{page}}"
oneComment = "1 comment"
nComments = "{{count}} comments"
addCommentPlaceholder = "Add comment..."
addLink = "Add link"
goToLink = "Go to link"
addComment = "Add comment"
viewComment = "View comment"
addReplyPlaceholder = "Add reply..."
saveReply = "Save reply"
send = "Send"
moreActions = "More actions"
typeComment = "Comment"
typeInsertText = "Insert Text"
typeReplaceText = "Replace Text"
locateAnnotation = "Locate in document"
deleteTitle = "Remove annotation from comments?"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
removeCommentOnly = "Remove comment only"
deleteAnnotationAndComment = "Delete annotation & comment"
[viewer.formBar]
title = "Form Fields"
unsavedBadge = "Unsaved"
+188 -77
View File
@@ -142,6 +142,10 @@ welcome = "Bienvenido"
white = "Blanco"
WorkInProgess = "Trabajo en curso. Puede no funcionar o tener errores. ¡Por favor, informa de cualquier problema!"
yes = "Sí"
insufficientCredits = "Créditos insuficientes. Requeridos: {{requiredCredits}}, Disponibles: {{currentBalance}}, Faltan: {{shortfall}}"
loadingCredits = "Comprobando créditos..."
loadingProStatus = "Comprobando estado de suscripción..."
noticeTopUpOrPlan = "No hay suficientes créditos, por favor recarga o actualiza tu plan"
[account]
accountSettings = "Configuración de la cuenta"
@@ -227,7 +231,7 @@ title = "Añadir imagen"
failed = "Se produjo un error al añadir la imagen al PDF."
[addImage.image]
hint = "Supports PNG, JPEG, GIF, BMP, TIFF, and SVG files. SVG files will be converted to PNG for embedding."
hint = "Soporta archivos PNG, JPEG, GIF, BMP, TIFF y SVG. Los archivos SVG se convertirán a PNG para su incrustación."
label = "Archivo de imagen"
name = "Imagen"
placeholder = "Suba una imagen"
@@ -275,8 +279,8 @@ preview = "Selección de posición"
previewDisclaimer = "La vista previa es aproximada. El resultado final puede variar debido a las métricas de fuentes del PDF."
submit = "Añadir Números de Página"
title = "Añadir Números de Página"
zeroPad = "Zero‑pad Width (Bates Stamping)"
zeroPadTooltip = "Zero‑pad (Bates Stamp) page numbers to this width (e.g., 3 ⇒ 001). Set 0 to disable."
zeroPad = "Ancho de relleno de ceros (Sello Bates)"
zeroPadTooltip = "Número de páginas con relleno de ceros (Sello Bates) para este ancho (ej., 3 ⇒ 001). Establezca 0 para desactivar."
[addPageNumbers.error]
failed = "La operación de añadir números de página falló"
@@ -345,53 +349,53 @@ title = "Cambiar permisos"
[AddStampRequest]
alphabet = "Alfabeto"
clickToExpand = "Click to expand"
clickToExpand = "Haz clic para expandir"
customColor = "Personalizar color de texto"
customDateDesc = "Custom format"
customDateDesc = "Formato personalizado"
customMargin = "Personalizar margen"
customPosition = "Arrastra el sello a la ubicación deseada en la ventana de vista previa."
dateDesc = "Current date"
datetimeDesc = "Date and time combined"
dateTimeVars = "Date & Time"
dynamicVariables = "Dynamic Variables"
examples = "Examples"
filenameDesc = "Filename without extension"
filenameFullDesc = "Filename with extension"
fileVars = "File Information"
dateDesc = "Fecha actual"
datetimeDesc = "Fecha y hora combinadas"
dateTimeVars = "Fecha y hora"
dynamicVariables = "Variables dinámicas"
examples = "Ejemplos"
filenameDesc = "Nombre de archivo sin extensión"
filenameFullDesc = "Nombre de archivo con extensión"
fileVars = "Información del archivo"
fontSize = "Tamaño de fuente/imagen"
header = "Sellar PDF"
imageSize = "Tamaño de la imagen"
margin = "Margen"
metadataDesc = "From PDF document properties"
metadataVars = "Document Metadata"
multiLine = "multi-line"
metadataDesc = "Desde las propiedades del documento PDF"
metadataVars = "Metadatos del documento"
multiLine = "multilínea"
noStampSelected = "No se ha seleccionado ningún sello. Vuelva al Paso 1."
opacity = "Opacidad"
otherVars = "Other"
otherVars = "Otros"
overrideX = "Forzar coordenada X"
overrideY = "Forzar coordenada Y"
pageNumberDesc = "Current page number"
pageVars = "Page Information"
pageNumberDesc = "Número de página actual"
pageVars = "Información de la página"
position = "Posición"
positionAndFormatting = "Posición y formato"
preview = "Preview:"
preview = "Vista previa:"
quickPosition = "Selecciona una posición en la página para colocar el sello."
rotation = "Rotación"
selectTemplate = "Select a template..."
selectTemplate = "Seleccione una plantilla..."
stampImage = "Imagen de sello"
stampSetup = "Configuración de sello"
stampText = "Texto del sello"
stampTextDescription = "Use dynamic variables below. Use @@ for literal @. Use \\n for new lines."
stampTextDescription = "Usa las variables dinámicas a continuación. Usa @@ para el @ literal. Usa \\n para nuevas líneas."
stampType = "Tipo de sello"
submit = "Enviar"
tags = "Sello, Añadir imagen, centrar imagen, Marca de agua, PDF, Incrustar, Personalizar"
timeDesc = "Current time"
timeDesc = "Hora actual"
title = "Sellar PDF"
totalPagesDesc = "Total number of pages"
useTemplate = "Use Template"
uuidDesc = "Short unique identifier (8 chars)"
variablesHelp = "Click on any variable to insert it into your stamp text. Use @@ for literal @."
yearMonthDayDesc = "Individual date parts"
totalPagesDesc = "Número total de páginas"
useTemplate = "Usar plantilla"
uuidDesc = "Identificador único corto (8 caracteres)"
variablesHelp = "Haz clic en cualquier variable para insertarla en el texto de tu sello. Usa @@ para el @ literal."
yearMonthDayDesc = "Partes de la fecha individuales"
[AddStampRequest.error]
failed = "Se produjo un error al añadir el sello al PDF."
@@ -400,17 +404,17 @@ failed = "Se produjo un error al añadir el sello al PDF."
title = "Resultados del sello"
[AddStampRequest.template]
custom = "Custom"
dateHeader = "Date Header"
doc-info = "Document Info"
draft = "Draft Watermark"
draftWatermark = "Draft Watermark"
european-date = "European Date (DD/MM/YYYY)"
europeanDate = "European Date"
legal-footer = "Legal Footer"
page-numbers = "Page Numbers"
pageNumberFooter = "Page Number Footer"
timestamp = "Timestamp"
custom = "Personalizado"
dateHeader = "Encabezado de fecha"
doc-info = "Información del documento"
draft = "Marca de agua de borrador"
draftWatermark = "Marca de agua de borrador"
european-date = "Fecha europea (DD/MM/AAAA)"
europeanDate = "Fecha europea"
legal-footer = "Pie de página legal"
page-numbers = "Números de página"
pageNumberFooter = "Pie de número de página"
timestamp = "Marca de tiempo"
[addText]
applySignatures = "Aplicar texto"
@@ -668,7 +672,7 @@ mobileScannerStretchToFit = "Ajustar para encajar"
mobileScannerStretchToFitDesc = "Estirar las imágenes para llenar toda la página. Si se desactiva, las imágenes se centran preservando la relación de aspecto."
pageFormatA4 = "A4 (210×297mm)"
pageFormatKeep = "Mantener (dimensiones originales)"
pageFormatLetter = "Letter (8.5×11in)"
pageFormatLetter = "Carta (8.5×11in)"
title = "Conexiones"
unlinkedServices = "Servicios no vinculados"
@@ -690,7 +694,7 @@ mobileScannerStretchToFitDesc = "Estirar las imágenes para llenar toda la pági
note = "Nota: Requiere configurar la Frontend URL. "
pageFormatA4 = "A4 (210×297mm)"
pageFormatKeep = "Mantener (dimensiones originales)"
pageFormatLetter = "Letter (8.5×11in)"
pageFormatLetter = "Carta (8.5×11in)"
[admin.settings.connections.oauth2]
label = "OAuth2"
@@ -949,16 +953,16 @@ description = "Directorio donde se generan los PDFs procesados (dejar vacío par
label = "Directorio de carpetas finalizadas"
[admin.settings.general.customPaths.pipeline.pipelineDir]
description = "Base directory for pipeline resources (leave empty for default: /pipeline)"
label = "Pipeline Directory"
description = "Directorio base para los recursos del pipeline (dejar vacío para el valor por defecto: /pipeline)"
label = "Directorio del Pipeline"
[admin.settings.general.customPaths.pipeline.watchedFoldersDir]
description = "Directorio donde el pipeline supervisa la llegada de PDFs (dejar vacío para el valor predeterminado: /pipeline/watchedFolders)"
label = "Directorio de carpetas vigiladas"
[admin.settings.general.customPaths.pipeline.watchedFoldersDirs]
description = "Directories where pipeline monitors for incoming PDFs (one per line or comma-separated; leave empty for default: /pipeline/watchedFolders)"
label = "Watched Folders Directories"
description = "Directorios donde el pipeline monitoriza los PDFs entrantes (uno por línea o separados por comas; dejar vacío para el valor por defecto: /pipeline/watchedFolders)"
label = "Directorios de Carpetas Monitorizadas"
[admin.settings.general.defaultLocale]
description = "Idioma predeterminado para nuevos usuarios (p. ej., en_US, es_ES)"
@@ -982,7 +986,7 @@ label = "Ocultar escáner móvil"
[admin.settings.general.frontendUrl]
description = "URL base para el frontend (p. ej., https://pdf.example.com). Se usa para enlaces de invitación por correo y subidas por código QR desde el móvil. Déjalo vacío para usar la URL del backend."
label = "Frontend URL"
label = "URL del Frontend"
[admin.settings.general.homeDescription]
description = "Texto descriptivo mostrado en la página de inicio"
@@ -1321,11 +1325,11 @@ message = "Los proveedores de autenticación OAuth2 y SAML2 se han movido al men
title = "¿Buscas la configuración de SSO/SAML?"
[admin.settings.security.xFrameOptions]
deny = "Deny (Prevents all framing)"
description = "Controls whether the application can be embedded in iframes"
disabled = "Disabled (No X-Frame-Options header)"
deny = "Denegar (Impide cualquier enmarcado)"
description = "Controla si la aplicación puede incrustarse en iframes"
disabled = "Desactivado (Sin encabezado X-Frame-Options)"
label = "X-Frame-Options"
sameorigin = "Same Origin (Allow framing from same domain)"
sameorigin = "Mismo Origen (Permite enmarcado desde el mismo dominio)"
[admin.settings.telegram]
description = "Configura la conectividad del bot de Telegram, controles de acceso y comportamiento de retroalimentación."
@@ -1347,7 +1351,7 @@ placeholder = "Añade el ID de usuario y pulsa Enter"
[admin.settings.telegram.botToken]
description = "Token de API proporcionado por BotFather para tu bot de Telegram."
label = "Bot Token"
label = "Token del Bot"
[admin.settings.telegram.botUsername]
description = "El nombre de usuario público de tu bot de Telegram."
@@ -2441,7 +2445,7 @@ title = "Estos PDF parecen muy diferentes"
[compare.edited]
label = "PDF editado"
placeholder = "Select the edited PDF"
placeholder = "Seleccionar el PDF editado"
[compare.error]
filesMissing = "No se pueden localizar los archivos seleccionados. Vuelve a seleccionarlos."
@@ -2574,10 +2578,22 @@ title = "Ajuste de Calidad"
[compressPdfs]
tags = "aplastar,pequeño,diminuto"
[config]
plan = "Plan"
[config.account.overview]
guestDescription = "Ha iniciado sesión como invitado. Considere actualizar su cuenta arriba."
manageAccountPreferences = "Administre las preferencias de su cuenta"
title = "Configuración de Cuenta"
confirmDelete = "Eliminar Mi Cuenta"
deleteAccount = "Eliminar Cuenta"
deleteAccountTitle = "Eliminar Cuenta"
deleteFailed = "Error al eliminar la cuenta."
deleteFailedTitle = "No se puede eliminar la cuenta"
deleteWarning = "Esta acción es permanente y no se puede deshacer. Se eliminarán todos tus datos."
enterEmailConfirm = "Para confirmar la eliminación, introduce tu dirección de correo electrónico ({{email}}) a continuación:"
label = "Resumen"
signedInAs = "Sesión iniciada como"
[config.account.upgrade]
description = "¡Vincule su cuenta para preservar su historial y acceder a más funciones!"
@@ -2592,6 +2608,32 @@ socialLogin = "Actualizar con Cuenta Social"
title = "Actualizar Cuenta de Invitado"
upgradeButton = "Actualizar Cuenta"
[config.account.profilePicture]
description = "Sube una imagen para personalizar tu cuenta."
help = "PNG, JPG, o WebP hasta 2MB."
remove = "Eliminar"
sizeError = "Por favor, selecciona una imagen de menos de 2MB."
switchedToCustom = "Cambiada a foto personalizada. Ahora puedes subir la tuya."
title = "Foto de perfil"
upload = "Subir"
useCustom = "Usar foto personalizada"
usingProvider = "Usando la foto de perfil de {{provider}}"
[config.account.profilePicture.cropper]
cropError = "Error al recortar la imagen. Por favor, inténtalo de nuevo."
invalidImage = "Archivo de imagen inválido. Por favor, selecciona un archivo PNG, JPG o WebP válido."
processing = "Procesando recorte..."
save = "Guardar Imagen Recortada"
sizeErrorAfterCrop = "La imagen recortada es demasiado grande. Por favor, aleja el zoom o recorta un área más pequeña."
title = "Recortar Foto de Perfil"
zoom = "Zoom"
[config.account.security]
changePassword = "Cambiar contraseña"
description = "Gestiona tu contraseña y los ajustes de seguridad."
title = "Contraseñas y Seguridad"
update = "Actualizar contraseña"
[config.apiKeys]
chartAriaLabel = "Uso de créditos: incluidos {{includedUsed}} de {{includedTotal}}, comprados {{purchasedUsed}} de {{purchasedTotal}}"
copyKeyAriaLabel = "Copiar clave API"
@@ -2614,6 +2656,7 @@ refreshAriaLabel = "Actualizar clave API"
schemaLink = "Referencia del esquema de la API"
totalCredits = "Créditos Totales"
usage = "Incluye esta clave en el encabezado X-API-KEY en todas las solicitudes a la API."
creditsRemaining = "Créditos Restantes"
[config.apiKeys.alert]
apiKeyErrorTitle = "Error de clave de API"
@@ -2658,8 +2701,8 @@ color = "Color"
colorType = "Tipo de Color"
combineImages = "Combinar Imágenes"
combineImagesDescription = "Combinar todas las imágenes en un PDF, o crear PDFs separados para cada imagen"
combineSvgs = "Combine SVGs into single PDF"
combineSvgsDescription = "Combine all SVG files into one PDF with multiple pages, or create separate PDFs for each SVG"
combineSvgs = "Combinar SVGs en un único PDF"
combineSvgsDescription = "Combina todos los archivos SVG en un PDF con múltiples páginas, o crea PDFs separados para cada SVG"
conversionCompleted = "Conversión completada"
conversionResults = "Resultados de Conversión"
convertFiles = "Convertir Archivos"
@@ -2716,10 +2759,10 @@ selectSourceFormatFirst = "Seleccione primero un formato de origen"
settings = "Configuración"
single = "Individual"
sourceFormatPlaceholder = "Formato de origen"
strictMode = "Strict Mode"
strictModeDesc = "Error if conversion is not perfect (uses VeraPDF verification)"
svgPdfOptions = "SVG to PDF Options"
svgVectorNote = "SVG files are rendered as vector graphics for crisp output at any resolution. Dimensions from the SVG determine the PDF page size."
strictMode = "Modo Estricto"
strictModeDesc = "Error si la conversión no es perfecta (usa verificación VeraPDF)"
svgPdfOptions = "Opciones SVG a PDF"
svgVectorNote = "Los archivos SVG se renderizan como gráficos vectoriales para una salida nítida en cualquier resolución. Las dimensiones del SVG determinan el tamaño de página del PDF."
targetFormatPlaceholder = "Formato de destino"
textRtf = "Texto/RTF"
title = "Convertir"
@@ -2728,6 +2771,15 @@ webOptions = "Opciones de Web a PDF"
wordDoc = "Documento de Word"
wordDocExt = "Documento de Word (.docx)"
zoomLevel = "Nivel de Zoom"
cbrToPdf = "CBR → PDF"
cbzToPdf = "CBZ → PDF"
ebookToPdf = "eBook → PDF"
emlToPdf = "Correo Electrónico → PDF"
fileToPdf = "Ofimática/Documento → PDF"
pdfToCbr = "PDF → CBR"
pdfToCbz = "PDF → CBZ"
pdfToEpub = "PDF → EPUB"
svgToPdf = "SVG → PDF"
[convert.ebookOptions]
ebookOptions = "Opciones de eBook a PDF"
@@ -3441,7 +3493,7 @@ title = "Numerar páginas"
[home.addPassword]
desc = "Cifrar documento PDF con contraseña"
tags = "encrypt,password,lock,secure,protect,security,encryption,safeguard,confidential,private,restrict access"
tags = "encriptar,contraseña,bloquear,seguro,proteger,seguridad,encriptación,salvaguardar,confidencial,privado,restringir acceso"
title = "Proteger con contraseña"
[home.addStamp]
@@ -3505,7 +3557,7 @@ title = "Cambiar metadatos"
[home.changePermissions]
desc = "Modificar restricciones y permisos del documento"
tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights"
tags = "permisos,restricciones,derechos,control de acceso,permitir,denegar,impresión,copia,edición,modificar permisos,ajustes de seguridad,derechos de usuario"
title = "Cambiar permisos"
[home.compare]
@@ -3530,7 +3582,7 @@ title = "Recortar PDF"
[home.devAirgapped]
desc = "Enlace a la guía de configuración aislada"
tags = "air-gapped,offline,isolated,no internet,disconnected,secure,network isolation,standalone"
tags = "air-gapped,offline,aislado,sin internet,desconectado,seguro,aislamiento de red,independiente"
title = "Configuración Aislada"
[home.devApi]
@@ -3545,7 +3597,7 @@ title = "Escaneo Automatizado de Carpetas"
[home.devSsoGuide]
desc = "Enlace a la guía de SSO"
tags = "SSO,single sign-on,authentication,SAML,OAuth,OIDC,login,enterprise,identity provider,IdP"
tags = "SSO,inicio de sesión único,autenticación,SAML,OAuth,OIDC,inicio de sesión,empresarial,proveedor de identidad,IdP"
title = "Guía de SSO"
[home.editTableOfContents]
@@ -3605,7 +3657,7 @@ title = "OCR / Limpiar escaneos"
[home.overlay-pdfs]
desc = "Superponer PDFs encima de otro PDF"
tags = "overlay,combine,merge,layer,superimpose,overlay PDF,layer PDFs,combine PDFs,stack,overlay pages,background,foreground,composite"
tags = "superponer,combinar,fusionar,capa,superposición de PDF,capas de PDF,combinar PDFs,apilar,superponer páginas,fondo,primer plano,compuesto"
title = "Superponer PDFs"
[home.pageLayout]
@@ -3620,7 +3672,7 @@ title = "Organizador"
[home.pdfTextEditor]
desc = "Revise y edite exportaciones JSON de Stirling PDF con edición de texto agrupada y regeneración de PDF"
tags = "edit text,modify text,change text,edit content,update text,rewrite,correct,amend,redline,revise,text editor,content editor"
tags = "editar texto,modificar texto,cambiar texto,editar contenido,actualizar texto,reescribir,corregir,modificar,revisar,editor de texto,editor de contenido"
title = "Editor de texto PDF"
[home.pdfToSinglePage]
@@ -3680,7 +3732,7 @@ title = "Reparar"
[home.replaceColor]
desc = "Reemplace o invierta colores en documentos PDF"
tags = "replace color,invert color,color replacement,swap colors,change color,invert,negative,color swap,find and replace color,convert color,color change"
tags = "reemplazar color,invertir color,reemplazo de color,intercambiar colores,cambiar color,invertir,negativo,intercambio de color,buscar y reemplazar color,convertir color,cambio de color"
title = "Reemplazar e Invertir Color"
[home.rotate]
@@ -3920,6 +3972,13 @@ username = "Nombre de usuario"
verifyingMfa = "Verificando..."
verifyMfa = "Verificar código"
youAreLoggedIn = "¡Ha iniciado sesión!"
backToSignIn = "Volver a iniciar sesión"
passwordUpdatedSuccess = "Tu contraseña se ha actualizado correctamente."
resetHelp = "Introduce tu correo electrónico para recibir un enlace seguro para restablecer tu contraseña. Si el enlace ha caducado, por favor solicita uno nuevo."
resetYourPassword = "Restablecer tu contraseña"
sendResetLink = "Enviar enlace de restablecimiento"
subtitle = "Vuelve a iniciar sesión en Stirling PDF"
updatePassword = "Actualizar contraseña"
[login.slides.edit]
alt = "Editar PDFs"
@@ -4199,7 +4258,7 @@ title = "Salida"
[onboarding]
activeFiles = "La vista de <strong>Archivos activos</strong> muestra todos los PDFs que has cargado en la herramienta y te permite seleccionar cuáles procesar."
allTools = "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools."
allTools = "Este es el panel de <strong>Todas las herramientas</strong>, donde puedes buscar y seleccionar entre todas las herramientas PDF disponibles."
cropSettings = "Ahora que hemos seleccionado el archivo que queremos recortar, podemos configurar la herramienta Recortar para elegir el área a la que queremos recortar el PDF."
fileCheckbox = "Al hacer clic en uno de los archivos lo seleccionas para procesarlo. Puedes seleccionar varios archivos para operaciones por lotes."
fileReplacement = "El archivo modificado reemplazará automáticamente al original en el Workbench, lo que te permitirá pasarlo fácilmente por más herramientas."
@@ -4280,6 +4339,15 @@ rightRail = "El <strong>Panel derecho</strong> contiene acciones rápidas para s
topBar = "La barra superior te permite cambiar entre <strong>Visor</strong>, <strong>Editor de páginas</strong> y <strong>Archivos activos</strong>."
wrapUp = "Eso es lo nuevo en V2. Abre el menú de <strong>Recorridos</strong> en cualquier momento para repetir este, el de Herramientas o el de Administración."
[onboarding.freeTrial]
afterTrialWithPayment = "Tu suscripción Pro comenzará automáticamente cuando finalice la prueba."
afterTrialWithoutPayment = "Tras finalizar tu prueba, continuarás en nuestro plan gratuito. Añade un método de pago para mantener el acceso Pro."
body = "Tienes acceso completo a las funciones de Stirling PDF Pro durante tu prueba. Disfruta de conversiones ilimitadas, tamaños de archivo mayores y procesamiento prioritario."
daysRemaining = "{{days}} días restantes"
daysRemainingSingular = "{{days}} día restante"
title = "Tu Prueba Pro de 30 Días"
trialEnds = "La prueba finaliza el {{date}}"
[overlay-pdfs]
desc = "Superponer un PDF encima de otro"
header = "Superponer archivos PDF"
@@ -4433,6 +4501,10 @@ exitRedaction = "Salir del modo de censura"
save = "Guardar"
downloadAll = "Descargar Todo"
saveAll = "Guardar todo"
readAloud = "Leer en Voz Alta"
readAloudLanguage = "Idioma"
readAloudSpeed = "Velocidad"
selectLanguage = "Seleccionar idioma"
[textAlign]
left = "Izquierda"
@@ -4588,7 +4660,7 @@ closeLater = "Lo haré más tarde"
continue = "Continuar"
description = "Lo usaremos para enviar su clave de licencia y recibos."
emailLabel = "Dirección de correo electrónico"
emailPlaceholder = "your@email.com"
emailPlaceholder = "tu@correo.com"
modalTitle = "Comenzar - {{planName}}"
title = "Introduzca su correo electrónico"
@@ -4980,6 +5052,9 @@ selectPlan = "Seleccionar plan"
showComparison = "Comparar todas las funciones"
upgrade = "Actualizar"
withServer = "+ Plan de servidor"
purchase = "Comprar"
selectCredits = "Seleccionar Cantidad de Créditos"
totalCost = "Costo Total"
[plan.activePlan]
subtitle = "Detalles de tu suscripción actual"
@@ -5069,6 +5144,30 @@ successMessage = "Tu licencia se ha activado correctamente. Ya puedes cerrar est
name = "Team"
siteLicense = "Licencia de sitio"
[plan.api]
large = "5.000 Créditos"
medium = "1.000 Créditos"
small = "500 Créditos"
xsmall = "100 Créditos"
[plan.apiPackages]
subtitle = "Compra créditos de API para tus aplicaciones"
title = "Paquetes de Créditos de API"
[plan.trial]
badge = "Prueba"
continueWithFree = "Continuar con Plan Gratuito"
daysRemaining = "Tu prueba termina en {{days}} días"
endDate = "Caduca: {{date}}"
expired = "Tu Prueba ha Finalizado"
expiredMessage = "Tu prueba Pro de 30 días ha caducado. Suscríbete a Pro para seguir accediendo a funciones premium, o continúa con nuestro plan gratuito."
freeTierLimitations = "El plan gratuito incluye herramientas básicas de PDF con límites de uso."
message = ""
subscribe = "Suscribirse a Pro"
subscribeToPro = "Suscribirse a Pro"
subscriptionScheduled = "Suscripción programada - comienza el {{date}}"
title = "Prueba Gratuita Activa"
[credits]
enableOverageBilling = "Habilitar facturación por excedente"
maybeLater = "Quizás más tarde"
@@ -5206,7 +5305,7 @@ failed = "Se produjo un error al censurar el PDF."
[redact.manual]
apply = "Aplicar"
applyChanges = "Aplicar Cambios"
applyRedactions = "Apply Redactions"
applyRedactions = "Aplicar Censuras"
applyWarning = "⚠️ Aplicación permanente, no se puede deshacer y los datos subyacentes se eliminarán"
boxRedaction = "Censura de dibujo de caja"
colourPicker = "Selector de Color"
@@ -5810,7 +5909,7 @@ trial = "Prueba"
[settings.planBilling.tier]
enterprise = "Enterprise"
enterpriseDescription = "Funciones y soporte empresariales personalizados"
free = "Free"
free = "Gratis"
freeDescription = "50 créditos al mes"
team = "Team"
teamBadge = "Team"
@@ -5884,13 +5983,13 @@ sidebar = "Barra lateral"
[settings.general.updates]
checkForUpdates = "Buscar actualizaciones"
currentBackendVersion = "Current Backend Version"
currentFrontendVersion = "Current Frontend Version"
currentBackendVersion = "Versión Actual del Backend"
currentFrontendVersion = "Versión Actual del Frontend"
description = "Compruebe actualizaciones y vea la información de versión"
latestVersion = "Última versión"
serverNeedsUpdate = "El servidor debe ser actualizado por el administrador"
title = "Actualizaciones de software"
versionMismatch = "Warning: A mismatch has been detected between the client version and the AppConfig version. Using different versions can lead to compatibility issues, errors, and security risks. Please ensure that server and client are using the same version."
versionMismatch = "Advertencia: Se ha detectado una discrepancia entre la versión del cliente y la versión de AppConfig. El uso de diferentes versiones puede provocar problemas de compatibilidad, errores y riesgos de seguridad. Por favor, asegúrese de que el servidor y el cliente utilizan la misma versión."
viewDetails = "Ver detalles"
[settings.general.versionInfo]
@@ -5966,7 +6065,10 @@ teams = "Equipos"
title = "Espacio de trabajo"
[settings.team]
title = "Team"
title = "Equipo"
[settings.search]
placeholder = "Buscar en las páginas de ajustes..."
[setup]
description = "Empiece eligiendo cómo quiere usar Stirling PDF"
@@ -5990,7 +6092,7 @@ subtitle = "Introduzca sus credenciales para continuar"
title = "Iniciar sesión"
[setup.login.email]
label = "Email"
label = "Correo Electrónico"
placeholder = "Introduzca su email"
[setup.login.error]
@@ -6028,7 +6130,7 @@ title = "Inicie sesión en el servidor"
subtitle = "Introduzca la URL de su servidor autoalojado"
testing = "Probando la conexión..."
title = "Conectar con el servidor"
useLast = "Last used server: {{serverUrl}}"
useLast = "Último servidor usado: {{serverUrl}}"
[setup.server.error]
configFetch = "No se pudo obtener la configuración del servidor. Compruebe la URL e inténtelo de nuevo."
@@ -6561,6 +6663,7 @@ showDetails = "Mostrar detalles"
unavailable = "Desactivado por el administrador del servidor:"
unavailableDependency = "No disponible - falta la herramienta requerida en el servidor:"
unfavorite = "Quitar de favoritos"
selfHostedOffline = "Requiere tu servidor Stirling-PDF (actualmente sin conexión):"
[toolPanel.modePrompt]
chooseFullscreen = "Usar modo pantalla completa"
@@ -7048,7 +7151,7 @@ team = "Equipo (opcional)"
teamPlaceholder = "Selecciona un equipo"
title = "Añadir miembro"
username = "Nombre de usuario (correo)"
usernamePlaceholder = "user@example.com"
usernamePlaceholder = "usuario@ejemplo.com"
usernameRequired = "Se requieren nombre de usuario y contraseña"
[workspace.people.authType]
@@ -7104,7 +7207,7 @@ title = "Editar miembro"
allFailed = "No se pudo invitar a los usuarios"
description = "Escribe o pega correos a continuación, separados por comas. Los usuarios recibirán credenciales de inicio de sesión por correo electrónico."
emails = "Direcciones de correo electrónico"
emailsPlaceholder = "user1@example.com, user2@example.com"
emailsPlaceholder = "usuario1@ejemplo.com, usuario2@ejemplo.com"
emailsRequired = "Se requiere al menos una dirección de correo electrónico"
error = "No se pudieron enviar las invitaciones"
partialFailure = "Algunas invitaciones fallaron"
@@ -7534,3 +7637,11 @@ description = "Clave de API de Google para Google Picker API de Google Cloud Con
[provider.googledrive.appId]
label = "ID de aplicación"
description = "ID de aplicación de Google Drive de Google Cloud Console"
[selfHosted.offline]
hideTools = "Ocultar herramientas no disponibles ▴"
messageNoFallback = "Las herramientas no estarán disponibles hasta que tu servidor vuelva a estar en línea."
messageWithFallback = "Alguna herramientas requieren conexión con el servidor."
showTools = "Ver herramientas no disponibles ▾"
title = "Tu servidor de Stirling-PDF está inaccesible"
toolNotAvailableLocally = "Tu servidor Stirling-PDF está sin conexión y \"{{endpoint}}\" no está disponible en el backend local."
@@ -1,5 +1,5 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core';
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import ColorizeIcon from '@mui/icons-material/Colorize';
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
@@ -20,6 +20,11 @@ interface ColorControlProps {
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
const [opened, setOpened] = useState(false);
// Buffer the colour locally so the picker stays responsive during drag.
// Only propagate to the parent (which triggers expensive annotation updates)
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
const [localColor, setLocalColor] = useState(value);
useEffect(() => { setLocalColor(value); }, [value]);
const handleEyeDropper = useCallback(async () => {
if (!supportsEyeDropper) return;
@@ -56,7 +61,7 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
},
}}
>
<ColorSwatch color={value} size={18} />
<ColorSwatch color={localColor} size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
@@ -64,8 +69,9 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
<Stack gap="xs">
<MantineColorPicker
format="hex"
value={value}
onChange={onChange}
value={localColor}
onChange={setLocalColor}
onChangeEnd={onChange}
swatches={[
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
@@ -76,7 +82,7 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper}>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper} style={{ color: 'var(--text-primary)' }}>
<ColorizeIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
@@ -1,17 +1,20 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationPatch } from '@app/components/viewer/viewerTypes';
import TuneIcon from '@mui/icons-material/Tune';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
type AnnotationType = 'text' | 'note' | 'shape';
export type PropertiesAnnotationType = 'text' | 'note' | 'shape';
interface PropertiesPopoverProps {
annotationType: AnnotationType;
annotation: any;
onUpdate: (patch: Record<string, any>) => void;
annotationType: PropertiesAnnotationType;
annotation: TrackedAnnotation<PdfAnnotationObject> | undefined;
onUpdate: (patch: AnnotationPatch) => void;
disabled?: boolean;
}
@@ -24,7 +27,15 @@ export function PropertiesPopover({
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const obj = annotation?.object;
interface AnnotationObjectProps {
fontSize?: number;
textAlign?: number | string;
opacity?: number;
borderWidth?: number;
strokeWidth?: number;
}
const obj = annotation?.object as (PdfAnnotationObject & AnnotationObjectProps) | undefined;
// Get current values
const fontSize = obj?.fontSize ?? 14;
@@ -134,68 +134,71 @@ export default function RightRail() {
[actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset]
);
const handleExportAll = useCallback(async () => {
if (currentView === 'viewer') {
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
if (!buffer) return;
const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
if (!fileToExport) return;
const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined;
try {
const result = await downloadFile({
data: new Blob([buffer], { type: 'application/pdf' }),
filename: fileToExport.name,
localPath: stub?.localFilePath,
});
if (!result.cancelled && stub && result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false,
});
}
} catch (error) {
console.error('[RightRail] Failed to export viewer file:', error);
}
return;
}
if (currentView === 'pageEditor') {
pageEditorFunctions?.onExportAll?.();
return;
}
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
if (filesToExport.length > 0) {
for (const file of filesToExport) {
const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined;
const handleExportAll = useCallback(
async (forceNewFile = false) => {
if (currentView === 'viewer') {
const buffer = await viewerContext?.exportActions?.saveAsCopy?.();
if (!buffer) return;
const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0];
if (!fileToExport) return;
const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined;
try {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: stub?.localFilePath
data: new Blob([buffer], { type: 'application/pdf' }),
filename: fileToExport.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
});
if (result.cancelled) continue;
if (stub && result.savedPath) {
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false
isDirty: false,
});
}
} catch (error) {
console.error('[RightRail] Failed to export file:', file.name, error);
console.error('[RightRail] Failed to export viewer file:', error);
}
return;
}
if (currentView === 'pageEditor') {
pageEditorFunctions?.onExportAll?.();
return;
}
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
if (filesToExport.length > 0) {
for (const file of filesToExport) {
const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined;
try {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
});
if (result.cancelled) continue;
if (!forceNewFile && stub && result.savedPath) {
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false,
});
}
} catch (error) {
console.error('[RightRail] Failed to export file:', file.name, error);
}
}
}
}
}, [
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
selectors,
fileActions,
]);
},
[
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
selectors,
fileActions,
]
);
const downloadTooltip = useMemo(() => {
if (currentView === 'pageEditor') {
@@ -264,7 +267,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
onClick={() => handleExportAll()}
disabled={
disableForFullscreen ||
(currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled))
@@ -276,6 +279,24 @@ export default function RightRail() {
tooltipPosition,
tooltipOffset
)}
{icons.saveAsIconName &&
renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => handleExportAll(true)}
disabled={
disableForFullscreen ||
(currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled))
}
>
<LocalIcon icon={icons.saveAsIconName} width="1.5rem" height="1.5rem" />
</ActionIcon>,
t('rightRail.saveAs', 'Save As'),
tooltipPosition,
tooltipOffset
)}
</div>
<div className="right-rail-spacer" />
@@ -81,6 +81,18 @@ type AnnotationDefaults =
imageSize?: { width: number; height: number };
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.TEXT;
strokeColor?: string;
opacity?: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.CARET;
strokeColor?: string;
opacity?: number;
customData?: Record<string, unknown>;
}
| null;
type AnnotationApiSurface = {
@@ -294,6 +306,24 @@ const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
stamp: buildStampDefaults,
signatureStamp: buildStampDefaults,
signatureInk: (options) => buildInkDefaults(options),
textComment: (options) => ({
type: PdfAnnotationSubtype.TEXT,
strokeColor: options?.color ?? DEFAULTS.note,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
insertText: (options) => ({
type: PdfAnnotationSubtype.CARET,
strokeColor: options?.color ?? '#E44234',
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
replaceText: (options) => ({
type: PdfAnnotationSubtype.CARET,
strokeColor: options?.color ?? '#E44234',
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
};
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
@@ -0,0 +1,156 @@
import { ActionIcon, Tooltip, Popover, TextInput, Button, Stack } from '@mantine/core';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import CommentIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import AddCommentIcon from '@mui/icons-material/AddCommentOutlined';
import OpenInNewIcon from '@mui/icons-material/OpenInNewRounded';
import LocalIcon from '@app/components/shared/LocalIcon';
import type { FirstLinkTarget } from '@app/components/viewer/useAnnotationMenuHandlers';
export const commonButtonStyles = {
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
};
export function DeleteButton({ onDelete }: { onDelete: () => void }) {
const { t } = useTranslation();
return (
<Tooltip label={t('annotation.delete', 'Delete')}>
<ActionIcon
variant="subtle"
color="red"
size="md"
onClick={onDelete}
styles={{
root: {
...commonButtonStyles.root,
'&:hover': {
backgroundColor: 'var(--mantine-color-red-1)',
borderColor: 'var(--mantine-color-red-4)',
color: 'var(--mantine-color-red-7)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
export function EditTextButton({ onEdit }: { onEdit: () => void }) {
const { t } = useTranslation();
return (
<Tooltip label={t('annotation.editText', 'Edit Text')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={onEdit} styles={commonButtonStyles}>
<EditIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
interface AttachCommentButtonProps {
isInSidebar: boolean;
onView: () => void;
onAdd: () => void;
}
export function AttachCommentButton({ isInSidebar, onView, onAdd }: AttachCommentButtonProps) {
const { t } = useTranslation();
return (
<Tooltip label={isInSidebar ? t('viewer.comments.viewComment', 'View comment') : t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon
variant={isInSidebar ? 'filled' : 'subtle'}
color={isInSidebar ? 'blue' : 'gray'}
size="md"
onClick={isInSidebar ? onView : onAdd}
styles={isInSidebar ? undefined : commonButtonStyles}
>
<AddCommentIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
interface CommentButtonProps {
hasContent: boolean;
onClick: () => void;
}
export function CommentButton({ hasContent, onClick }: CommentButtonProps) {
const { t } = useTranslation();
return (
<Tooltip label={hasContent ? t('viewer.comments.viewComment', 'View comment') : t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={onClick} styles={commonButtonStyles}>
<CommentIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
interface LinkButtonProps {
firstLinkTarget: FirstLinkTarget | null;
onGoToLink: () => void;
onAddLink: (url: string) => void;
}
export function LinkButton({ firstLinkTarget, onGoToLink, onAddLink }: LinkButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState('');
if (firstLinkTarget) {
return (
<Tooltip label={t('viewer.comments.goToLink', 'Go to link')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={onGoToLink} styles={commonButtonStyles}>
<OpenInNewIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
return (
<Popover opened={open} onClose={() => setOpen(false)} position="top">
<Popover.Target>
<Tooltip label={t('viewer.comments.addLink', 'Add link')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={() => setOpen((o) => !o)} styles={commonButtonStyles}>
<LocalIcon icon="link" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<TextInput
placeholder="https://..."
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
size="sm"
style={{ minWidth: 220 }}
/>
<Button
size="xs"
disabled={!url.trim()}
onClick={() => {
onAddLink(url);
setUrl('');
setOpen(false);
}}
>
{t('viewer.comments.addLink', 'Add link')}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,18 +1,14 @@
import { ActionIcon, Tooltip, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Group } from '@mantine/core';
import { createPortal } from 'react-dom';
import { useEffect, useState, useRef, useCallback } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationPatch, AnnotationObject } from '@app/components/viewer/viewerTypes';
import { PdfAnnotationSubtype, type PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationObject } from '@app/components/viewer/viewerTypes';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover';
import { ColorControl } from '@app/components/annotation/shared/ColorControl';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAnnotationMenuHandlers } from '@app/components/viewer/useAnnotationMenuHandlers';
import { AnnotationTypeButtons } from '@app/components/viewer/AnnotationTypeButtons';
/**
* Props interface matching EmbedPDF's annotation selection menu pattern
@@ -34,22 +30,10 @@ export interface AnnotationSelectionMenuProps {
export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) {
const activeDocumentId = useActiveDocumentId();
// Don't render until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return (
<AnnotationSelectionMenuInner
documentId={activeDocumentId}
{...props}
/>
);
if (!activeDocumentId) return null;
return <AnnotationSelectionMenuInner documentId={activeDocumentId} {...props} />;
}
type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown';
function AnnotationSelectionMenuInner({
documentId,
context,
@@ -58,366 +42,56 @@ function AnnotationSelectionMenuInner({
}: AnnotationSelectionMenuProps & { documentId: string }) {
const annotation = context?.annotation;
const pageIndex = context?.pageIndex;
const { t } = useTranslation();
const { provides } = useAnnotation(documentId);
const { scrollActions, requestCommentFocus } = useViewer();
const wrapperRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
// Merge refs - menuWrapperProps.ref is a callback ref
const handlers = useAnnotationMenuHandlers({
annotation,
pageIndex,
documentId,
provides,
scrollActions,
requestCommentFocus,
wrapperRef,
});
// Auto-open the comments sidebar when a comment annotation is selected
useEffect(() => {
const annObj = annotation?.object as AnnotationObject | undefined;
const annId = annObj?.id;
if (!selected || !annId || pageIndex === undefined) return;
const toolId = annObj?.customData?.toolId;
const annType = annObj?.type;
const isComment =
(annType === PdfAnnotationSubtype.TEXT && toolId === 'textComment') ||
(annType === PdfAnnotationSubtype.CARET && (toolId === 'insertText' || toolId === 'replaceText'));
if (!isComment) return;
requestCommentFocus(documentId, pageIndex, annId, (annObj?.contents ?? '').trim().length > 0);
}, [selected, annotation?.object]);
// Click outside to deselect
useEffect(() => {
if (!selected) return;
const handlePointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-annotation-selection-menu]')) return;
if (target.closest('[data-no-interaction]')) return;
if (target.closest('.mantine-Popover-dropdown')) return;
provides?.deselectAnnotation?.();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [selected, provides]);
// Merge refs — menuWrapperProps.ref is a callback ref from EmbedPDF
const setRef = useCallback((node: HTMLDivElement | null) => {
wrapperRef.current = node;
// Call the EmbedPDF ref callback
menuWrapperProps?.ref?.(node);
}, [menuWrapperProps]);
// Type detection
const getAnnotationType = useCallback((): AnnotationType => {
const type = annotation?.object?.type;
const toolId = (annotation?.object as AnnotationObject | undefined)?.customData?.toolId;
// Map type numbers to categories
if (type !== undefined && [9, 10, 11, 12].includes(type)) return 'textMarkup';
if (type === 15) {
return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
}
if (type === 3) {
return toolId === 'note' ? 'note' : 'text';
}
if (type !== undefined && [5, 6, 7].includes(type)) return 'shape';
if (type !== undefined && [4, 8].includes(type)) return 'line';
if (type === 13) return 'stamp';
return 'unknown';
}, [annotation]);
// Calculate menu width based on annotation type
const calculateWidth = (annotationType: AnnotationType): number => {
switch (annotationType) {
case 'stamp':
return 80;
case 'inkHighlighter':
return 220;
case 'shape':
return 200;
default:
return 180;
}
};
// Get annotation properties
const obj = annotation?.object as AnnotationObject | undefined;
const annotationType = getAnnotationType();
const annotationId = obj?.id;
// Get current colors
const getCurrentColor = (): string => {
if (!obj) return '#000000';
const type = obj.type;
// Text annotations use textColor
if (type === 3) return obj.textColor || obj.color || '#000000';
// Shape annotations use strokeColor
if (type !== undefined && [4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
// Default to color property
return obj.color || obj.strokeColor || '#000000';
};
const getStrokeColor = (): string => {
return obj?.strokeColor || obj?.color || '#000000';
};
const getFillColor = (): string => {
return obj?.color || obj?.fillColor || '#0000ff';
};
const getBackgroundColor = (): string => {
// Check multiple possible properties for background color
return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
};
const getTextColor = (): string => {
return obj?.textColor || obj?.color || '#000000';
};
const getOpacity = (): number => {
return Math.round((obj?.opacity ?? 1) * 100);
};
const getWidth = (): number => {
return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
};
// Handlers
const handleDelete = useCallback(() => {
if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
provides.deleteAnnotation(pageIndex, annotationId);
}
}, [provides, annotationId, pageIndex]);
// Focus inline text input (same as double-clicking the note/text box). Dispatches dblclick on the
// annotation hit-area div (EmbedPDF's inner div with onDoubleClick) so built-in FreeText editing is used.
const handleFocusTextEdit = useCallback(() => {
const root = wrapperRef.current?.closest('[data-no-interaction]');
const main = root?.firstElementChild;
// EmbedPDF puts onDoubleClick on the content div. For text/note (no rotation) it's the first child.
const hitArea = main?.lastElementChild ?? main?.firstElementChild;
if (!hitArea) return;
hitArea.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }));
}, []);
const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const type = obj?.type;
const patch: AnnotationPatch = {};
if (target === 'stroke') {
// Shape stroke - preserve fill color
patch.strokeColor = color;
patch.color = obj?.color || '#0000ff'; // Preserve fill
patch.strokeWidth = getWidth();
} else if (target === 'fill') {
// Shape fill - preserve stroke color
patch.color = color;
patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke
patch.strokeWidth = getWidth();
} else if (target === 'background') {
// Background color for text/note - set multiple properties for compatibility
patch.backgroundColor = color;
patch.fillColor = color;
patch.color = color;
} else if (target === 'text') {
// Text color for text/note - TRY PROPERTY COMBINATIONS
patch.textColor = color;
patch.fontColor = color; // EmbedPDF might expect this instead
// Include font metadata (EmbedPDF might require these together)
patch.fontSize = obj?.fontSize ?? 14;
patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
// Re-submit text content
patch.contents = obj?.contents ?? '';
} else {
// Main color - for highlights, ink, etc.
patch.color = color;
// For text markup annotations (highlight, underline, strikeout, squiggly)
if (type !== undefined && [9, 10, 11, 12].includes(type)) {
patch.strokeColor = color;
patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1;
}
// For line annotations (type 4, 8), include stroke properties
if (type !== undefined && [4, 8].includes(type)) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
}
// For ink annotations (type 15), include all stroke-related properties
if (type === 15) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
patch.opacity = obj?.opacity ?? 1;
}
}
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex, obj]);
const handleOpacityChange = useCallback((opacity: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
opacity: opacity / 100,
});
}, [provides, annotationId, pageIndex]);
const handleWidthChange = useCallback((width: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
strokeWidth: width,
});
}, [provides, annotationId, pageIndex]);
const handlePropertiesUpdate = useCallback((patch: Record<string, any>) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex]);
// Render button groups based on annotation type
const renderButtons = () => {
const commonButtonStyles = {
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
};
const EditTextButton = () => (
<Tooltip label={t('annotation.editText', 'Edit Text')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={handleFocusTextEdit}
styles={commonButtonStyles}
>
<EditIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
const DeleteButton = () => (
<Tooltip label={t('annotation.delete', 'Delete')}>
<ActionIcon
variant="subtle"
color="red"
size="md"
onClick={handleDelete}
styles={{
root: {
...commonButtonStyles.root,
'&:hover': {
backgroundColor: 'var(--mantine-color-red-1)',
borderColor: 'var(--mantine-color-red-4)',
color: 'var(--mantine-color-red-7)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
switch (annotationType) {
case 'textMarkup':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'ink':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'inkHighlighter':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={20} />
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'text':
case 'note':
return (
<>
<ColorControl
value={getTextColor()}
onChange={(color) => handleColorChange(color, 'text')}
label={t('annotation.color', 'Color')}
/>
<ColorControl
value={getBackgroundColor()}
onChange={(color) => handleColorChange(color, 'background')}
label={t('annotation.backgroundColor', 'Background color')}
/>
<EditTextButton />
<PropertiesPopover
annotationType={annotationType}
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'shape':
return (
<>
<ColorControl
value={getStrokeColor()}
onChange={(color) => handleColorChange(color, 'stroke')}
label={t('annotation.strokeColor', 'Stroke Colour')}
/>
<ColorControl
value={getFillColor()}
onChange={(color) => handleColorChange(color, 'fill')}
label={t('annotation.fillColor', 'Fill Colour')}
/>
<PropertiesPopover
annotationType="shape"
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'line':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'stamp':
return <DeleteButton />;
default:
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<DeleteButton />
</>
);
}
};
// Calculate position for portal based on wrapper element
// Track menu position via MutationObserver (handles drag repositioning)
useEffect(() => {
if (!selected || !annotation || !wrapperRef.current) {
setMenuPosition(null);
@@ -426,24 +100,15 @@ function AnnotationSelectionMenuInner({
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) {
setMenuPosition(null);
return;
}
const wrapperRect = wrapper.getBoundingClientRect();
setMenuPosition({
top: wrapperRect.bottom + 8,
left: wrapperRect.left + wrapperRect.width / 2,
});
if (!wrapper) { setMenuPosition(null); return; }
const rect = wrapper.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 8, left: rect.left + rect.width / 2 });
};
updatePosition();
// MutationObserver catches EmbedPDF updating the wrapper's inline style during drag
const observer = new MutationObserver(updatePosition);
observer.observe(wrapperRef.current, { attributes: true, attributeFilter: ['style'] });
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
@@ -452,9 +117,8 @@ function AnnotationSelectionMenuInner({
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [selected, annotation]);
}, [selected]);
// Early return AFTER all hooks have been called
if (!selected || !annotation) return null;
const menuContent = menuPosition ? (
@@ -466,39 +130,34 @@ function AnnotationSelectionMenuInner({
left: `${menuPosition.left}px`,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
zIndex: 10000, // Very high z-index to appear above everything
zIndex: 10000,
backgroundColor: 'var(--mantine-color-body)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
border: '1px solid var(--mantine-color-default-border)',
fontSize: '14px',
minWidth: `${calculateWidth(annotationType)}px`,
minWidth: `${handlers.menuWidth}px`,
transition: 'min-width 0.2s ease',
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
{renderButtons()}
<AnnotationTypeButtons
{...handlers}
annotation={annotation}
documentId={documentId}
pageIndex={pageIndex}
annotationId={handlers.annotationId}
/>
</Group>
</div>
) : null;
return (
<>
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps.
Must stay pointerEvents:none so EmbedPDF's internal drag handlers receive events.
Edit Text button dispatches dblclick on this wrapper's parent to use EmbedPDF's built-in inline editing. */}
<div
ref={setRef}
style={{
...menuWrapperProps?.style,
opacity: 0,
pointerEvents: 'none',
}}
/>
{typeof document !== 'undefined' && menuContent
? createPortal(menuContent, document.body)
: null}
{/* Invisible wrapper for EmbedPDF positioning — pointer-events:none so drag still works */}
<div ref={setRef} style={{ ...menuWrapperProps?.style, opacity: 0, pointerEvents: 'none' }} />
{typeof document !== 'undefined' && menuContent ? createPortal(menuContent, document.body) : null}
</>
);
}
@@ -0,0 +1,201 @@
import { useTranslation } from 'react-i18next';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
import { PropertiesPopover, type PropertiesAnnotationType } from '@app/components/annotation/shared/PropertiesPopover';
import { ColorControl } from '@app/components/annotation/shared/ColorControl';
import {
DeleteButton,
EditTextButton,
AttachCommentButton,
CommentButton,
LinkButton,
} from '@app/components/viewer/AnnotationMenuButtons';
import type { AnnotationType, AnnotationMenuState, AnnotationMenuHandlers } from '@app/components/viewer/useAnnotationMenuHandlers';
export interface AnnotationTypeButtonsProps extends AnnotationMenuState, AnnotationMenuHandlers {
annotation: TrackedAnnotation<PdfAnnotationObject> | undefined;
documentId: string;
pageIndex: number | undefined;
annotationId: string | undefined;
}
export function AnnotationTypeButtons(props: AnnotationTypeButtonsProps) {
const { t } = useTranslation();
const {
annotationType,
currentColor,
strokeColor,
fillColor,
backgroundColor,
textColor,
currentOpacity,
currentWidth,
hasCommentContent,
isInSidebar,
firstLinkTarget,
obj,
annotation,
onDelete,
onEdit,
onColorChange,
onOpacityChange,
onWidthChange,
onPropertiesUpdate,
onGoToLink,
onAddLink,
onAddToSidebar,
onViewComment,
onCommentColorChange,
} = props;
const attachCommentButton = (
<AttachCommentButton isInSidebar={isInSidebar} onView={onViewComment} onAdd={onAddToSidebar} />
);
switch (annotationType as AnnotationType) {
case 'textMarkup':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<OpacityControl value={currentOpacity} onChange={onOpacityChange} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'ink':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={currentWidth} onChange={onWidthChange} min={1} max={12} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'inkHighlighter':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={currentWidth} onChange={onWidthChange} min={1} max={20} />
<OpacityControl value={currentOpacity} onChange={onOpacityChange} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'text':
case 'note':
return (
<>
{attachCommentButton}
<ColorControl
value={textColor}
onChange={(color) => onColorChange(color, 'text')}
label={t('annotation.color', 'Color')}
/>
<ColorControl
value={backgroundColor}
onChange={(color) => onColorChange(color, 'background')}
label={t('annotation.backgroundColor', 'Background color')}
/>
<EditTextButton onEdit={onEdit} />
<PropertiesPopover
annotationType={annotationType as PropertiesAnnotationType}
annotation={annotation}
onUpdate={onPropertiesUpdate}
/>
<DeleteButton onDelete={onDelete} />
</>
);
case 'comment':
return (
<>
<CommentButton hasContent={hasCommentContent} onClick={onViewComment} />
<EditTextButton onEdit={onEdit} />
<ColorControl
value={obj?.strokeColor || obj?.color || '#ffa000'}
onChange={onCommentColorChange}
label={t('annotation.annotationStyle', 'Annotation style')}
/>
<LinkButton firstLinkTarget={firstLinkTarget} onGoToLink={onGoToLink} onAddLink={onAddLink} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'shape':
return (
<>
{attachCommentButton}
<ColorControl
value={strokeColor}
onChange={(color) => onColorChange(color, 'stroke')}
label={t('annotation.strokeColor', 'Stroke Colour')}
/>
<ColorControl
value={fillColor}
onChange={(color) => onColorChange(color, 'fill')}
label={t('annotation.fillColor', 'Fill Colour')}
/>
<PropertiesPopover
annotationType="shape"
annotation={annotation}
onUpdate={onPropertiesUpdate}
/>
<DeleteButton onDelete={onDelete} />
</>
);
case 'line':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={currentWidth} onChange={onWidthChange} min={1} max={12} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'stamp':
return (
<>
{attachCommentButton}
<DeleteButton onDelete={onDelete} />
</>
);
default:
return (
<>
{attachCommentButton}
<CommentButton hasContent={hasCommentContent} onClick={onViewComment} />
<LinkButton firstLinkTarget={firstLinkTarget} onGoToLink={onGoToLink} onAddLink={onAddLink} />
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<DeleteButton onDelete={onDelete} />
</>
);
}
}
@@ -11,6 +11,7 @@ import '@app/components/viewer/AttachmentSidebar.css';
interface AttachmentSidebarProps {
visible: boolean;
thumbnailVisible: boolean;
bookmarkVisible: boolean;
documentCacheKey?: string;
preloadCacheKeys?: string[];
}
@@ -32,7 +33,7 @@ const createEntry = (overrides: Partial<AttachmentCacheEntry> = {}): AttachmentC
...overrides,
});
export const AttachmentSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: AttachmentSidebarProps) => {
export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, documentCacheKey, preloadCacheKeys = [] }: AttachmentSidebarProps) => {
const { t } = useTranslation();
const { attachmentActions, hasAttachmentSupport } = useViewer();
const [searchTerm, setSearchTerm] = useState('');
@@ -286,7 +287,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, documentCacheKey,
className="attachment-sidebar"
style={{
position: 'fixed',
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
right: `${(thumbnailVisible ? 15 : 0) + (bookmarkVisible ? 15 : 0)}rem`,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
@@ -0,0 +1,704 @@
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { Box, ScrollArea, Text, Textarea, Stack, ActionIcon, Group, Tooltip, TextInput, Menu, Modal, Button, UnstyledButton } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckIcon from '@mui/icons-material/CheckRounded';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import EditIcon from '@mui/icons-material/Edit';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
import { getSidebarAnnotationsWithRepliesGroupedByPage } from '@embedpdf/plugin-annotation';
import { PdfAnnotationSubtype, PdfAnnotationReplyType } from '@embedpdf/models';
import { useCommentAuthor } from '@app/contexts/CommentAuthorContext';
import { useViewer } from '@app/contexts/ViewerContext';
import LocalIcon from '@app/components/shared/LocalIcon';
const SIDEBAR_WIDTH = '18rem';
/** Format annotation date for display (e.g. "Mar 11, 6:05 PM"). */
function formatCommentDate(obj: any): string {
const raw = obj?.modifiedDate ?? obj?.creationDate ?? obj?.customData?.modifiedDate ?? obj?.M;
if (raw == null) return '';
const d = raw instanceof Date ? raw : new Date(raw);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
interface CommentsSidebarProps {
documentId: string;
visible: boolean;
rightOffset: string;
}
function getCommentDisplayContent(entry: { annotation: { object: any }; replies: Array<{ object: any }> }): string {
const main = entry.annotation?.object?.contents;
if (main != null && String(main).trim()) return String(main).trim();
const firstReply = entry.replies?.[0]?.object?.contents;
if (firstReply != null && String(firstReply).trim()) return String(firstReply).trim();
return '';
}
/** Placeholder authors we never show; use current user's name from context instead. */
const PLACEHOLDER_AUTHORS = new Set(['Guest', 'Digital Signature', '']);
function getAuthorName(obj: any, currentDisplayName: string): string {
const stored = (obj?.author ?? 'Guest').trim() || 'Guest';
if (PLACEHOLDER_AUTHORS.has(stored)) return currentDisplayName || 'Guest';
return stored;
}
/** Replies store an explicit author; only allow edit when it matches the current comment author name. */
function isReplyAuthoredByCurrentUser(obj: any, currentDisplayName: string): boolean {
const stored = (obj?.author ?? '').trim() || 'Guest';
// Resolve current user the same way getAuthorName does
const resolvedMine = PLACEHOLDER_AUTHORS.has((currentDisplayName ?? '').trim())
? 'Guest'
: (currentDisplayName ?? '').trim();
const resolvedStored = PLACEHOLDER_AUTHORS.has(stored) ? 'Guest' : stored;
// Both are guest/anonymous → same unauthenticated user, allow editing
if (resolvedStored === 'Guest' && resolvedMine === 'Guest') return true;
if (!resolvedMine) return false;
return resolvedStored === resolvedMine;
}
// Map toolId → LocalIcon icon name (matches AnnotationPanel icon definitions)
const TOOL_ICON_MAP: Record<string, string> = {
highlight: 'highlight',
underline: 'format-underlined',
strikeout: 'strikethrough-s',
squiggly: 'show-chart',
ink: 'edit',
inkHighlighter: 'brush',
square: 'crop-square',
circle: 'radio-button-unchecked',
line: 'show-chart',
lineArrow: 'show-chart',
polyline: 'show-chart',
polygon: 'change-history',
text: 'text-fields',
note: 'sticky-note-2',
stamp: 'add-photo-alternate',
textComment: 'comment',
insertText: 'add-comment',
replaceText: 'find-replace',
};
// Type-based fallback icon when no toolId is present
function getIconByType(type: number | undefined): string {
if (type === 1) return 'comment';
if (type === 3) return 'sticky-note-2';
if (type === 4 || type === 8) return 'show-chart';
if (type === 5) return 'crop-square';
if (type === 6) return 'radio-button-unchecked';
if (type === 7 || type === 8) return 'change-history';
if (type === 9) return 'highlight';
if (type === 10) return 'format-underlined';
if (type === 11) return 'show-chart';
if (type === 12) return 'strikethrough-s';
if (type === 13) return 'add-photo-alternate';
if (type === 14) return 'add-comment';
if (type === 15) return 'edit';
return 'comment';
}
function isCommentAnnotation(ann: any): boolean {
const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId;
if (toolId === 'textComment' || toolId === 'insertText' || toolId === 'replaceText') return true;
// Any annotation explicitly added to comments via the "Add comment" button
if (ann?.customData?.isComment === true) return true;
// CARET (type 14) = insertText/replaceText; TEXT (type 1) = textComment
if (!toolId && (ann?.type === 14 || ann?.type === 1)) return true;
return false;
}
function getAnnotationToolId(ann: any): string {
return ann?.customData?.toolId ?? ann?.customData?.annotationToolId ?? '';
}
function getAnnotationTypeLabel(ann: any, t: (key: string, fallback: string) => string): string {
const toolId = getAnnotationToolId(ann);
const labels: Record<string, string> = {
highlight: t('annotation.highlight', 'Highlight'),
underline: t('annotation.underline', 'Underline'),
strikeout: t('annotation.strikeout', 'Strikeout'),
squiggly: t('annotation.squiggly', 'Squiggly'),
ink: t('annotation.pen', 'Pen'),
inkHighlighter: t('annotation.freehandHighlighter', 'Freehand Highlighter'),
square: t('annotation.square', 'Square'),
circle: t('annotation.circle', 'Circle'),
line: t('annotation.line', 'Line'),
lineArrow: t('annotation.lineArrow', 'Arrow'),
polyline: t('annotation.polyline', 'Polyline'),
polygon: t('annotation.polygon', 'Polygon'),
text: t('annotation.text', 'Text box'),
note: t('annotation.note', 'Note'),
stamp: t('annotation.stamp', 'Stamp'),
textComment: t('viewer.comments.typeComment', 'Comment'),
insertText: t('viewer.comments.typeInsertText', 'Insert Text'),
replaceText: t('viewer.comments.typeReplaceText', 'Replace Text'),
};
if (labels[toolId]) return labels[toolId];
// Type-based fallback (mirrors getIconByType) for annotations without customData.toolId
const type = ann?.type;
if (type === 14) return t('viewer.comments.typeInsertText', 'Insert Text');
if (type === 1) return t('viewer.comments.typeComment', 'Comment');
return t('viewer.comments.typeComment', 'Comment');
}
function AnnotationTypeIcon({ ann }: { ann: any }) {
const toolId = getAnnotationToolId(ann);
const iconName = TOOL_ICON_MAP[toolId] ?? getIconByType(ann?.type);
return (
<LocalIcon
icon={iconName}
width="1.25rem"
height="1.25rem"
style={{ flexShrink: 0, color: 'var(--mantine-color-blue-5)' }}
/>
);
}
export function CommentsSidebar({ documentId, visible, rightOffset }: CommentsSidebarProps) {
const { t } = useTranslation();
const { displayName } = useCommentAuthor();
const { highlightCommentRequest, clearHighlightCommentRequest, scrollActions, getZoomState } = useViewer() ?? {};
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const { state, provides } = useAnnotation(documentId);
const [draftContents, setDraftContents] = useState<Record<string, string>>({});
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
/** Draft text while editing an existing reply (`${pageIndex}_${parentId}_${replyId}`). */
const [replyEditDrafts, setReplyEditDrafts] = useState<Record<string, string>>({});
/** When set, this card's main comment is in edit mode (show textarea for main comment). */
const [editingMainKey, setEditingMainKey] = useState<string | null>(null);
/** Which reply is in edit mode (same key shape as replyEditDrafts). */
const [editingReplyKey, setEditingReplyKey] = useState<string | null>(null);
// React to request to focus or highlight a comment card (e.g. from "Add comment" / "View comment" in selection menu)
useEffect(() => {
if (!visible || !highlightCommentRequest || highlightCommentRequest.documentId !== documentId) return;
const { pageIndex, annotationId, action } = highlightCommentRequest;
const cardKey = `${pageIndex}_${annotationId}`;
const root = scrollViewportRef.current;
if (!root) return;
const card = root.querySelector<HTMLElement>(`[data-comment-card="${cardKey}"]`);
if (!card) {
clearHighlightCommentRequest?.();
return;
}
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (action === 'highlight') {
card.classList.remove('comment-card-flash-highlight');
void card.offsetWidth;
card.classList.add('comment-card-flash-highlight');
const tId = window.setTimeout(() => {
card.classList.remove('comment-card-flash-highlight');
clearHighlightCommentRequest?.();
}, 1500);
return () => window.clearTimeout(tId);
}
// action === 'focus': focus the first textarea or reply input in the card
const input = card.querySelector<HTMLTextAreaElement | HTMLInputElement>('textarea, input');
if (input) {
requestAnimationFrame(() => {
input.focus();
});
}
clearHighlightCommentRequest?.();
}, [visible, highlightCommentRequest, documentId, clearHighlightCommentRequest]);
const handleLocateAnnotation = useCallback((pageIndex: number, ann: any) => {
scrollActions?.scrollToPage(pageIndex + 1, 'smooth');
setTimeout(() => {
const pageEl = document.querySelector<HTMLElement>(`[data-page-index="${pageIndex}"]`);
if (!pageEl || !ann?.rect) return;
const zoom = getZoomState?.()?.currentZoom ?? 1;
const { origin, size } = ann.rect as { origin: { x: number; y: number }; size: { width: number; height: number } };
const flashEl = document.createElement('div');
// Append to page element so it scrolls with the page (position: absolute relative to page)
flashEl.style.cssText = `
position: absolute;
left: ${origin.x * zoom}px;
top: ${origin.y * zoom}px;
width: ${size.width * zoom}px;
height: ${size.height * zoom}px;
background: rgba(255, 213, 0, 0.55);
border: 2px solid rgba(255, 170, 0, 0.8);
border-radius: 3px;
pointer-events: none;
z-index: 9998;
animation: annotation-locate-flash 1.6s ease-out forwards;
`;
if (!document.getElementById('annotation-locate-flash-style')) {
const style = document.createElement('style');
style.id = 'annotation-locate-flash-style';
style.textContent = `@keyframes annotation-locate-flash {
0% { opacity: 0; transform: scale(1.08); }
15% { opacity: 1; transform: scale(1); }
70% { opacity: 1; }
100% { opacity: 0; }
}`;
document.head.appendChild(style);
}
pageEl.appendChild(flashEl);
setTimeout(() => flashEl.remove(), 1700);
}, 550);
}, [scrollActions, getZoomState]);
const byPage = useMemo(() => {
try {
const all = getSidebarAnnotationsWithRepliesGroupedByPage(state) ?? {};
const filtered: typeof all = {};
for (const [page, entries] of Object.entries(all)) {
const commentEntries = (entries as typeof entries).filter(
(e) => isCommentAnnotation((e as any).annotation?.object)
);
if (commentEntries.length > 0) {
filtered[Number(page)] = commentEntries;
}
}
return filtered;
} catch {
return {};
}
}, [state]);
// Derive the set of selected annotation IDs from EmbedPDF's selection state.
// state is AnnotationDocumentState — selectedUids are keys in byUid, and may equal id.
const selectedAnnotationIds = useMemo(() => {
const selectedUids: string[] = state?.selectedUids ?? [];
const byUid: Record<string, any> = (state as any)?.byUid ?? {};
const ids = new Set<string>();
for (const uid of selectedUids) {
// uid itself may be the annotation id
ids.add(uid);
const annId = byUid[uid]?.object?.id;
if (annId) ids.add(annId);
}
return ids;
}, [state]);
const pageNumbers = useMemo(() => Object.keys(byPage).map(Number).sort((a, b) => a - b), [byPage]);
const totalCount = useMemo(() => pageNumbers.reduce((sum, p) => sum + (byPage[p]?.length ?? 0), 0), [pageNumbers, byPage]);
const handleContentsChange = useCallback(
(pageIndex: number, annotationId: string, value: string) => {
setDraftContents((prev) => ({ ...prev, [pageIndex + '_' + annotationId]: value }));
if (!provides?.updateAnnotation) return;
provides.updateAnnotation(pageIndex, annotationId, { contents: value });
},
[provides]
);
const [deleteModal, setDeleteModal] = useState<{ pageIndex: number; id: string; ann: any } | null>(null);
const isLinkedAnnotation = (ann: any) => {
const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId;
return ann?.customData?.isComment === true &&
toolId !== 'textComment' && toolId !== 'insertText' && toolId !== 'replaceText';
};
const handleDeleteClick = useCallback(
(pageIndex: number, annotationId: string, ann: any) => {
if (isLinkedAnnotation(ann)) {
setDeleteModal({ pageIndex, id: annotationId, ann });
} else {
provides?.deleteAnnotation?.(pageIndex, annotationId);
}
},
[provides]
);
const handleRemoveFromSidebar = useCallback(() => {
if (!deleteModal || !provides?.updateAnnotation) return;
const { pageIndex, id, ann } = deleteModal;
const existing = (ann?.customData ?? {}) as Record<string, unknown>;
const { isComment: _removed, ...rest } = existing;
provides.updateAnnotation(pageIndex, id, { customData: rest } as any);
setDeleteModal(null);
}, [deleteModal, provides]);
const handleDeleteAnnotation = useCallback(() => {
if (!deleteModal) return;
provides?.deleteAnnotation?.(deleteModal.pageIndex, deleteModal.id);
setDeleteModal(null);
}, [deleteModal, provides]);
const handleSendMainComment = useCallback(
(pageIndex: number, annotationId: string, value: string) => {
const trimmed = value.trim();
if (!trimmed || !provides?.updateAnnotation) return;
provides.updateAnnotation(pageIndex, annotationId, { contents: trimmed, author: displayName });
setDraftContents((prev) => ({ ...prev, [pageIndex + '_' + annotationId]: trimmed }));
},
[provides, displayName]
);
const handleSendReply = useCallback(
(pageIndex: number, parentId: string, parentRect: any) => {
const key = `${pageIndex}_${parentId}_reply`;
const text = replyDrafts[key]?.trim();
if (!text || !provides?.createAnnotation) return;
const rect = parentRect ?? { origin: { x: 0, y: 0 }, size: { width: 1, height: 1 } };
provides.createAnnotation(pageIndex, {
type: PdfAnnotationSubtype.TEXT,
id: `reply-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
pageIndex,
rect,
contents: text,
inReplyToId: parentId,
replyType: PdfAnnotationReplyType.Reply,
author: displayName,
} as any);
setReplyDrafts((prev) => ({ ...prev, [key]: '' }));
},
[provides, replyDrafts, displayName]
);
const handleSaveReplyEdit = useCallback(
(editKey: string, pageIndex: number, replyId: string, value: string) => {
const trimmed = value.trim();
if (!trimmed || !provides?.updateAnnotation) return;
provides.updateAnnotation(pageIndex, replyId, { contents: trimmed, author: displayName });
setReplyEditDrafts((prev) => {
const next = { ...prev };
delete next[editKey];
return next;
});
setEditingReplyKey(null);
},
[provides, displayName]
);
if (!visible) return null;
return (
<Box
ref={scrollViewportRef}
style={{
position: 'fixed',
right: rightOffset,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
backgroundColor: 'var(--right-rail-bg)',
borderLeft: '1px solid var(--border-subtle)',
zIndex: 998,
display: 'flex',
flexDirection: 'column',
boxShadow: '-2px 0 8px rgba(0, 0, 0, 0.1)',
}}
>
<div
style={{
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--border-subtle)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
<LocalIcon icon="comment" width="1.25rem" height="1.25rem" style={{ color: 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
{t('viewer.comments.title', 'Comments')}
</Text>
</div>
<ScrollArea style={{ flex: 1 }}>
<Stack p="sm" gap="md">
{totalCount === 0 ? (
<Text size="sm" c="dimmed">
{t('viewer.comments.hint', 'Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.')}
</Text>
) : (
pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
const pageNum = pageIndex + 1;
return (
<Box key={pageIndex} mb="md">
<Text size="sm" fw={700} mb={2}>
{t('viewer.comments.pageLabel', 'Page {{page}}', { page: pageNum })}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{entries.length === 1
? t('viewer.comments.oneComment', '1 comment')
: t('viewer.comments.nComments', '{{count}} comments', { count: entries.length })}
</Text>
<Box
mb="xs"
style={{
borderBottom: '1px solid var(--border-subtle)',
}}
/>
<Stack gap="sm">
{entries.map((entry) => {
const ann = entry.annotation?.object;
const id = ann?.id;
if (!id) return null;
const key = `${pageIndex}_${id}`;
const replyKey = `${pageIndex}_${id}_reply`;
const displayContent = getCommentDisplayContent(entry);
const draft = draftContents[key] !== undefined ? draftContents[key] : displayContent;
const replyDraft = replyDrafts[replyKey] ?? '';
const authorName = getAuthorName(ann, displayName);
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
const hasMainContent = (displayContent ?? '').trim().length > 0;
const isEditingMain = editingMainKey === key;
const mainTimestamp = formatCommentDate(ann);
const typeLabel = getAnnotationTypeLabel(ann, t);
return (
<Box
key={key}
data-comment-card={key}
p="sm"
style={{
border: selectedAnnotationIds.has(id)
? '1px solid var(--mantine-color-blue-3)'
: '1px solid var(--border-subtle)',
borderRadius: 8,
backgroundColor: 'var(--bg-raised)',
}}
>
<Group wrap="nowrap" gap="xs" justify="space-between" align="flex-start" mb="xs">
<Group wrap="nowrap" gap="xs" style={{ minWidth: 0, flex: 1 }}>
<AnnotationTypeIcon ann={ann} />
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{authorName}
</Text>
<Text size="xs" c="dimmed">
{typeLabel}{mainTimestamp ? ` · ${mainTimestamp}` : ''}
</Text>
</Box>
</Group>
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip label={t('viewer.comments.locateAnnotation', 'Locate in document')}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={() => handleLocateAnnotation(pageIndex, ann)}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip label={t('viewer.comments.moreActions', 'More actions')}>
<ActionIcon variant="subtle" size="sm" color="gray">
<MoreHorizIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<EditIcon style={{ fontSize: 18 }} />}
onClick={() => setEditingMainKey(key)}
>
{t('annotation.editText', 'Edit')}
</Menu.Item>
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 18 }} />}
color="red"
onClick={() => handleDeleteClick(pageIndex, id, ann)}
>
{t('annotation.delete', 'Delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
{!hasMainContent || isEditingMain ? (
<>
<Textarea
placeholder={t('viewer.comments.addCommentPlaceholder', 'Add comment...')}
minRows={2}
autosize
value={draft ?? ''}
onChange={(e) => {
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
setDraftContents((prev) => ({ ...prev, [key]: v }));
if (isEditingMain) {
handleContentsChange(pageIndex, id, v);
}
}}
styles={{ root: { width: '100%' } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip label={t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() => {
handleSendMainComment(pageIndex, id, draft ?? '');
setEditingMainKey(null);
}}
disabled={!(draft ?? '').trim()}
>
<CheckIcon style={{ fontSize: 18, color: 'white' }} />
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<>
<Text size="sm" mb="sm" style={{ whiteSpace: 'pre-wrap' }}>
{displayContent}
</Text>
{entry.replies?.length ? (
<Stack gap="sm" mb="sm">
{entry.replies.map((r) => {
const rObj = r?.object;
const rId = rObj?.id;
if (!rId) return null;
const rAuthor = getAuthorName(rObj, displayName);
const rTimestamp = formatCommentDate(rObj);
const replyEditKey = `${pageIndex}_${id}_${rId}`;
const isEditingReply = editingReplyKey === replyEditKey;
const canEditReply = isReplyAuthoredByCurrentUser(rObj, displayName);
const replyBody =
replyEditDrafts[replyEditKey] !== undefined
? replyEditDrafts[replyEditKey]
: (rObj?.contents ?? '');
return (
<Box key={rId} pl="xs" style={{ borderLeft: '2px solid var(--mantine-color-blue-3)' }}>
<Box style={{ minWidth: 0 }}>
<Group wrap="nowrap" justify="space-between" align="flex-start" gap={4} mb={2}>
<Text size="sm" fw={600}>
{rAuthor}
</Text>
<Group wrap="nowrap" gap="xs" align="center">
{canEditReply && !isEditingReply ? (
<UnstyledButton
type="button"
onClick={() => {
setEditingReplyKey(replyEditKey);
setReplyEditDrafts(() => ({
[replyEditKey]: String(rObj?.contents ?? ''),
}));
}}
>
<Text size="xs" c="blue">
{t('annotation.editText', 'Edit')}
</Text>
</UnstyledButton>
) : null}
{rTimestamp ? (
<Text size="xs" c="dimmed">
{rTimestamp}
</Text>
) : null}
</Group>
</Group>
{isEditingReply ? (
<>
<Textarea
minRows={2}
autosize
value={replyBody}
onChange={(e) => {
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
setReplyEditDrafts((p) => ({ ...p, [replyEditKey]: v }));
}}
styles={{ root: { width: '100%' } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip label={t('viewer.comments.saveReply', 'Save reply')}>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() =>
handleSaveReplyEdit(replyEditKey, pageIndex, rId, replyBody)
}
disabled={!replyBody.trim()}
>
<CheckIcon style={{ fontSize: 18, color: 'white' }} />
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{rObj?.contents ?? ''}
</Text>
)}
</Box>
</Box>
);
})}
</Stack>
) : null}
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
placeholder={t('viewer.comments.addReplyPlaceholder', 'Add reply...')}
size="xs"
value={replyDraft}
onChange={(e) => {
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
setReplyDrafts((p) => ({ ...p, [replyKey]: v }));
}}
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor: 'var(--mantine-color-blue-3)',
},
}}
/>
<Tooltip label={t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon
variant="filled"
size="md"
color="blue"
style={{ backgroundColor: 'var(--mantine-color-blue-6)' }}
onClick={() => handleSendReply(pageIndex, id, ann?.rect)}
disabled={!replyDraft.trim()}
>
<CheckIcon style={{ fontSize: 20, color: 'white' }} />
</ActionIcon>
</Tooltip>
</Group>
</>
)}
</Box>
);
})}
</Stack>
</Box>
);
})
)}
</Stack>
</ScrollArea>
<Modal
opened={!!deleteModal}
onClose={() => setDeleteModal(null)}
title={t('viewer.comments.deleteTitle', 'Remove annotation from comments?')}
centered
size="sm"
>
<Text size="sm" c="dimmed" mb="lg">
{t(
'viewer.comments.deleteDescription',
'This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything.'
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleRemoveFromSidebar}>
{t('viewer.comments.removeCommentOnly', 'Remove comment only')}
</Button>
<Button color="red" onClick={handleDeleteAnnotation}>
{t('viewer.comments.deleteAnnotationAndComment', 'Delete annotation & comment')}
</Button>
</Group>
</Modal>
</Box>
);
}
@@ -126,6 +126,7 @@ const EmbedPdfViewerContent = ({
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
isAttachmentSidebarVisible,
isCommentsSidebarVisible,
isSearchInterfaceVisible,
searchInterfaceActions,
zoomActions,
@@ -201,8 +202,9 @@ const EmbedPdfViewerContent = ({
const isSignatureMode = isInAnnotationTool;
const isManualRedactMode = selectedTool === 'redact';
// Enable annotations only when annotation tool is selected
const shouldEnableAnnotations = selectedTool === 'annotate' || isSignatureMode;
// Enable annotations when annotation tool is selected OR when annotations are visible
// (so users can interact with existing comment annotations in reader/viewer mode)
const shouldEnableAnnotations = selectedTool === 'annotate' || isSignatureMode || isAnnotationsVisible;
// Enable redaction only when redaction tool is selected
const shouldEnableRedaction = selectedTool === 'redact';
@@ -397,16 +399,6 @@ const EmbedPdfViewerContent = ({
event.preventDefault();
printActions.print();
return;
case 'r':
case 'R':
// Ctrl+R: Rotate forward; Ctrl+Shift+R: Rotate backward
event.preventDefault();
if (event.shiftKey) {
rotationActions.rotateBackward();
} else {
rotationActions.rotateForward();
}
return;
}
}
}
@@ -920,10 +912,12 @@ const EmbedPdfViewerContent = ({
}, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields]);
const sidebarWidthRem = 15;
const commentsSidebarWidthRem = 18;
const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
(isBookmarkSidebarVisible ? sidebarWidthRem : 0) +
(isAttachmentSidebarVisible ? sidebarWidthRem : 0);
(isAttachmentSidebarVisible ? sidebarWidthRem : 0) +
(isCommentsSidebarVisible ? commentsSidebarWidthRem : 0);
return (
<Box
@@ -979,6 +973,7 @@ const EmbedPdfViewerContent = ({
(effectiveFile?.file instanceof File ? effectiveFile.file.name : undefined))
}
enableAnnotations={shouldEnableAnnotations}
isSignMode={selectedTool === 'sign'}
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
enableFormFill={shouldEnableFormFill}
@@ -988,6 +983,8 @@ const EmbedPdfViewerContent = ({
historyApiRef={historyApiRef as React.RefObject<any>}
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
fileId={currentFileId}
isCommentsSidebarVisible={isCommentsSidebarVisible}
commentsSidebarRightOffset={`${(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + (isAttachmentSidebarVisible ? sidebarWidthRem : 0)}rem`}
onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed
// Future: Handle signature completion
@@ -1053,6 +1050,7 @@ const EmbedPdfViewerContent = ({
<AttachmentSidebar
visible={isAttachmentSidebarVisible}
thumbnailVisible={isThumbnailSidebarVisible}
bookmarkVisible={isBookmarkSidebarVisible}
documentCacheKey={bookmarkCacheKey}
preloadCacheKeys={allBookmarkCacheKeys}
/>
@@ -61,6 +61,9 @@ import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrappe
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
import { absoluteWithBasePath } from '@app/constants/app';
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
import { CommentsSidebar } from '@app/components/viewer/CommentsSidebar';
import { CommentAuthorProvider } from '@app/contexts/CommentAuthorContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -78,12 +81,19 @@ interface LocalEmbedPDFProps {
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
/** File identity passed through to FormFieldOverlay for stale-field guards */
fileId?: string | null;
/** Comments sidebar visibility and offset (from EmbedPdfViewer) */
isCommentsSidebarVisible?: boolean;
commentsSidebarRightOffset?: string;
/** When true, blocks the general ink/pen annotation tool (sign tool context). */
isSignMode?: boolean;
}
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId, isCommentsSidebarVisible = false, commentsSidebarRightOffset = '0rem', isSignMode = false }: LocalEmbedPDFProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: Rect}>>([]);
const commentAuthorName = config?.username ?? 'Guest';
// Convert File to URL if needed
useEffect(() => {
@@ -655,6 +665,27 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
rect: event.annotation.rect
}]);
// If the annotation doesn't have customData.toolId, patch it from the active tool.
// EmbedPDF doesn't always persist customData from setToolDefaults into created annotations.
const annotationId = event.annotation.id;
const existingCustomData = (event.annotation as unknown as { customData?: Record<string, unknown> }).customData;
if (annotationId && !existingCustomData?.toolId) {
const activeTool = (annotationApi as unknown as { getActiveTool?: () => { id: string } | null }).getActiveTool?.();
if (activeTool?.id && activeTool.id !== 'select') {
(annotationApi as unknown as { updateAnnotation?: (page: number, id: string, patch: Record<string, unknown>) => void })
.updateAnnotation?.(event.pageIndex, annotationId, {
customData: { ...(existingCustomData ?? {}), toolId: activeTool.id },
});
}
}
// Auto-select the annotation after creation so the selection menu appears immediately,
// letting users discover the editing options before they click away.
if (annotationId) {
(annotationApi as unknown as { selectAnnotation?: (pageIndex: number, id: string) => void })
.selectAnnotation?.(event.pageIndex, annotationId);
}
if (onSignatureAdded) {
onSignatureAdded(event.annotation);
}
@@ -678,7 +709,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && <RedactionAPIBridge />}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <SignatureAPIBridge ref={signatureApiRef} />}
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <SignatureAPIBridge ref={signatureApiRef} isSignMode={isSignMode} />}
{(enableRedaction || isManualRedactionMode) && <RedactionPendingTracker ref={redactionTrackerRef} />}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
@@ -699,6 +730,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
}
>
{(documentId) => (
<>
<GlobalPointerProvider documentId={documentId}>
<Viewport
documentId={documentId}
@@ -793,6 +825,16 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
/>
</Viewport>
</GlobalPointerProvider>
{enableAnnotations && (
<CommentAuthorProvider displayName={commentAuthorName}>
<CommentsSidebar
documentId={documentId}
visible={isCommentsSidebarVisible}
rightOffset={commentsSidebarRightOffset}
/>
</CommentAuthorProvider>
)}
</>
)}
</DocumentReadyWrapper>
</ActiveDocumentProvider>
@@ -97,11 +97,13 @@ export function PdfViewerToolbar({
};
const handlePreviousPage = () => {
scrollActions.scrollToPreviousPage();
const { currentPage: cur } = getScrollState();
if (cur > 1) scrollActions.scrollToPage(cur - 1);
};
const handleNextPage = () => {
scrollActions.scrollToNextPage();
const { currentPage: cur, totalPages: tot } = getScrollState();
if (cur < tot) scrollActions.scrollToPage(cur + 1);
};
const handleLastPage = () => {
@@ -133,7 +133,12 @@ const createTextStampImage = (
};
};
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
interface SignatureAPIBridgeProps {
/** When true (sign tool context), the general ink/pen annotation tool is blocked. */
isSignMode?: boolean;
}
export const SignatureAPIBridge = forwardRef<SignatureAPI, SignatureAPIBridgeProps>(function SignatureAPIBridge({ isSignMode = false }, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize, setSignaturesApplied } = useSignature();
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
@@ -151,6 +156,14 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
};
}, [getZoomState, registerImmediateZoomUpdate]);
// When entering sign mode, deactivate any active annotation tool immediately.
// Only signature-specific tools (signatureInk, stamp) should be usable.
useEffect(() => {
if (isSignMode && annotationApi && documentReady) {
annotationApi.setActiveTool(null);
}
}, [isSignMode, annotationApi, documentReady]);
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
@@ -302,13 +315,12 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
activateDrawMode: () => {
if (!annotationApi) return;
// Activate the built-in ink tool for drawing
annotationApi.setActiveTool('ink');
// Use signatureInk (not ink) so the annotation pen tool stays unaffected
annotationApi.setActiveTool('signatureInk');
// Set default ink tool properties (black color, 2px width)
const activeTool = annotationApi.getActiveTool();
if (activeTool && activeTool.id === 'ink') {
annotationApi.setToolDefaults('ink', {
if (activeTool && activeTool.id === 'signatureInk') {
annotationApi.setToolDefaults('signatureInk', {
color: '#000000',
thickness: 2,
lineWidth: 2,
@@ -338,11 +350,16 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
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);
// Only reactivate ink if it was already the active tool — never auto-activate it.
// This prevents the ink (pen annotation) tool from being activated when
// updateDrawSettings is called on sign-tool mount.
const currentTool = annotationApi.getActiveTool();
if (currentTool?.id === 'ink') {
annotationApi.setActiveTool(null);
setTimeout(() => {
annotationApi.setActiveTool('ink');
}, 50);
}
},
activateDeleteMode: () => {
@@ -0,0 +1,289 @@
import { useCallback, useMemo, type RefObject } from 'react';
import { PdfActionType, PdfAnnotationSubtype, PdfAnnotationReplyType, type PdfAnnotationObject } from '@embedpdf/models';
import type { TrackedAnnotation, AnnotationScope } from '@embedpdf/plugin-annotation';
import type { AnnotationObject, AnnotationPatch } from '@app/components/viewer/viewerTypes';
import type { ScrollActions } from '@app/contexts/viewer/viewerActions';
export type AnnotationType =
| 'textMarkup'
| 'ink'
| 'inkHighlighter'
| 'text'
| 'note'
| 'comment'
| 'shape'
| 'line'
| 'stamp'
| 'unknown';
export type FirstLinkTarget =
| { type: 'uri'; uri: string }
| { type: 'goto'; pageIndex: number };
interface UseAnnotationMenuHandlersParams {
annotation: TrackedAnnotation<PdfAnnotationObject> | undefined;
pageIndex: number | undefined;
documentId: string;
provides: AnnotationScope | null;
scrollActions: ScrollActions;
requestCommentFocus: (documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => void;
wrapperRef: RefObject<HTMLDivElement | null>;
}
export interface AnnotationMenuState {
annotationType: AnnotationType;
menuWidth: number;
obj: AnnotationObject | undefined;
annotationId: string | undefined;
firstLinkTarget: FirstLinkTarget | null;
hasCommentContent: boolean;
isInSidebar: boolean;
currentColor: string;
strokeColor: string;
fillColor: string;
backgroundColor: string;
textColor: string;
currentOpacity: number;
currentWidth: number;
}
export interface AnnotationMenuHandlers {
onDelete: () => void;
onEdit: () => void;
onColorChange: (color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => void;
onOpacityChange: (opacity: number) => void;
onWidthChange: (width: number) => void;
onPropertiesUpdate: (patch: AnnotationPatch) => void;
onGoToLink: () => void;
onAddLink: (url: string) => void;
onAddToSidebar: () => void;
onViewComment: () => void;
onCommentColorChange: (color: string) => void;
}
export function useAnnotationMenuHandlers({
annotation,
pageIndex,
documentId,
provides,
scrollActions,
requestCommentFocus,
wrapperRef,
}: UseAnnotationMenuHandlersParams): AnnotationMenuState & AnnotationMenuHandlers {
const obj = annotation?.object as AnnotationObject | undefined;
const annotationId = obj?.id;
const attachedLinks = useMemo(() => {
if (!annotationId || !provides?.getAttachedLinks) return [];
try {
return provides.getAttachedLinks(annotationId) ?? [];
} catch {
return [];
}
}, [annotationId, provides]);
const firstLinkTarget = useMemo<FirstLinkTarget | null>(() => {
const linkObj = (attachedLinks[0]?.object as {
target?: {
type: string;
action?: { type: number; uri?: string; destination?: { pageIndex: number } };
};
} | undefined);
if (!linkObj?.target || linkObj.target.type !== 'action') return null;
const act = linkObj.target.action;
if (act && act.type === PdfActionType.URI && act.uri) return { type: 'uri', uri: act.uri };
if (act && (act.type === PdfActionType.Goto || act.type === PdfActionType.RemoteGoto) && act.destination)
return { type: 'goto', pageIndex: act.destination.pageIndex };
return null;
}, [attachedLinks]);
const annotationType = useMemo((): AnnotationType => {
const type = annotation?.object?.type;
const toolId = (annotation?.object as AnnotationObject | undefined)?.customData?.toolId;
if (type !== undefined && [9, 10, 11, 12].includes(type)) return 'textMarkup';
if (type === 15) return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
if (type === 1 && toolId === 'textComment') return 'comment';
if (type === 14 && (toolId === 'insertText' || toolId === 'replaceText')) return 'comment';
if (type === 3) return 'note';
if (type !== undefined && [5, 6, 7].includes(type)) return 'shape';
if (type !== undefined && [4, 8].includes(type)) return 'line';
if (type === 13) return 'stamp';
return 'unknown';
}, [annotation]);
const menuWidth = useMemo((): number => {
switch (annotationType) {
case 'stamp': return 80;
case 'inkHighlighter':
case 'comment':
case 'textMarkup':
case 'text':
case 'note': return 280;
case 'shape': return 200;
default: return 260;
}
}, [annotationType]);
const hasCommentContent = (obj?.contents ?? '').trim().length > 0;
const isInSidebar = (obj?.customData as Record<string, unknown> | undefined)?.isComment === true;
// Derived style values
const currentColor = (() => {
if (!obj) return '#000000';
const type = obj.type;
if (type === 3) return obj.textColor || obj.color || '#000000';
if (type !== undefined && [4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
return obj.color || obj.strokeColor || '#000000';
})();
const strokeColor = obj?.strokeColor || obj?.color || '#000000';
const fillColor = obj?.color || obj?.fillColor || '#0000ff';
const backgroundColor = obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
const textColor = obj?.textColor || obj?.color || '#000000';
const currentOpacity = Math.round((obj?.opacity ?? 1) * 100);
const currentWidth = obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
// Handlers
const onDelete = useCallback(() => {
if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
provides.deleteAnnotation(pageIndex, annotationId);
}
}, [provides, annotationId, pageIndex]);
const onEdit = useCallback(() => {
const root = wrapperRef.current?.closest('[data-no-interaction]');
const main = root?.firstElementChild;
const hitArea = main?.lastElementChild ?? main?.firstElementChild;
if (!hitArea) return;
hitArea.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }));
}, [wrapperRef]);
const onColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const type = obj?.type;
const patch: AnnotationPatch = {};
if (target === 'stroke') {
patch.strokeColor = color;
patch.color = obj?.color || '#0000ff';
patch.strokeWidth = currentWidth;
} else if (target === 'fill') {
patch.color = color;
patch.strokeColor = obj?.strokeColor || '#000000';
patch.strokeWidth = currentWidth;
} else if (target === 'background') {
patch.backgroundColor = color;
patch.fillColor = color;
patch.color = color;
} else if (target === 'text') {
patch.textColor = color;
patch.fontColor = color;
patch.fontSize = obj?.fontSize ?? 14;
patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
patch.contents = obj?.contents ?? '';
} else {
patch.color = color;
if (type !== undefined && [9, 10, 11, 12].includes(type)) {
patch.strokeColor = color;
patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1;
}
if (type !== undefined && [4, 8].includes(type)) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
}
if (type === 15) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
patch.opacity = obj?.opacity ?? 1;
}
}
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex, obj, currentWidth]);
const onOpacityChange = useCallback((opacity: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, { opacity: opacity / 100 });
}, [provides, annotationId, pageIndex]);
const onWidthChange = useCallback((width: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, { strokeWidth: width });
}, [provides, annotationId, pageIndex]);
const onPropertiesUpdate = useCallback((patch: Record<string, unknown>) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex]);
const onGoToLink = useCallback(() => {
if (!firstLinkTarget) return;
if (firstLinkTarget.type === 'uri') {
window.open(firstLinkTarget.uri, '_blank', 'noopener,noreferrer');
} else {
scrollActions.scrollToPage(firstLinkTarget.pageIndex + 1);
}
}, [firstLinkTarget, scrollActions]);
const onAddLink = useCallback((url: string) => {
const uri = url.trim();
if (!uri || !provides?.createAnnotation || pageIndex === undefined || !annotationId || !obj?.rect) return;
provides.createAnnotation(pageIndex, {
type: PdfAnnotationSubtype.LINK,
id: `link-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
pageIndex,
rect: obj.rect,
target: { type: 'action', action: { type: PdfActionType.URI, uri } },
inReplyToId: annotationId,
replyType: PdfAnnotationReplyType.Group,
});
}, [provides, pageIndex, annotationId, obj?.rect]);
const onAddToSidebar = useCallback(() => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const existingCustomData = (obj?.customData ?? {}) as Record<string, unknown>;
provides.updateAnnotation(pageIndex, annotationId, {
customData: { ...existingCustomData, isComment: true },
} as AnnotationPatch);
requestCommentFocus(documentId, pageIndex, annotationId, false);
}, [provides, annotationId, pageIndex, obj, documentId, requestCommentFocus]);
const onViewComment = useCallback(() => {
requestCommentFocus(documentId, pageIndex ?? 0, annotationId ?? '', hasCommentContent);
}, [requestCommentFocus, documentId, pageIndex, annotationId, hasCommentContent]);
const onCommentColorChange = useCallback((color: string) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, { strokeColor: color, color });
}, [provides, annotationId, pageIndex]);
return {
annotationType,
menuWidth,
obj,
annotationId,
firstLinkTarget,
hasCommentContent,
isInSidebar,
currentColor,
strokeColor,
fillColor,
backgroundColor,
textColor,
currentOpacity,
currentWidth,
onDelete,
onEdit,
onColorChange,
onOpacityChange,
onWidthChange,
onPropertiesUpdate,
onGoToLink,
onAddLink,
onAddToSidebar,
onViewComment,
onCommentColorChange,
};
}
@@ -26,7 +26,7 @@ export function useViewerRightRailButtons(
) {
const { t, i18n } = useTranslation();
const viewer = useViewer();
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isCommentsSidebarVisible, toggleCommentsSidebar, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
@@ -63,10 +63,8 @@ export function useViewerRightRailButtons(
if (selectedTool === 'annotate') {
setIsAnnotationsActive(true);
} else if (selectedTool) {
// Any other tool is active — annotate button should not be highlighted
setIsAnnotationsActive(false);
} else {
// No tool selected — fall back to URL path check
setIsAnnotationsActive(isAnnotationsPath());
}
}, [selectedTool, isAnnotationsPath]);
@@ -85,6 +83,7 @@ export function useViewerRightRailButtons(
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments');
const commentsLabel = t('rightRail.toggleComments', 'Comments');
const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const formFillLabel = t('rightRail.formFill', 'Fill Form');
@@ -237,13 +236,25 @@ export function useViewerRightRailButtons(
viewer.toggleAttachmentSidebar();
}
},
{
id: 'viewer-toggle-comments',
icon: <LocalIcon icon="comment" width="1.5rem" height="1.5rem" />,
tooltip: commentsLabel,
ariaLabel: commentsLabel,
section: 'top' as const,
order: 56.5,
active: isCommentsSidebarVisible,
onClick: () => {
toggleCommentsSidebar();
}
},
{
id: 'viewer-print',
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
tooltip: printLabel,
ariaLabel: printLabel,
section: 'top' as const,
order: 56,
order: 57,
onClick: () => {
viewer.printActions.print();
}
@@ -71,6 +71,9 @@ export type AnnotationToolId =
| 'inkHighlighter'
| 'text'
| 'note'
| 'textComment'
| 'insertText'
| 'replaceText'
| 'square'
| 'circle'
| 'line'
@@ -84,7 +87,6 @@ export type AnnotationToolId =
// Import for internal use within this file, and re-export for external consumers
import type { AnnotationEvent } from '@embedpdf/plugin-annotation';
export type { AnnotationEvent } from '@embedpdf/plugin-annotation';
export type { PdfAnnotationObject } from '@embedpdf/models';
export type AnnotationPatch = Record<string, unknown>;
@@ -142,4 +144,5 @@ export interface AnnotationToolOptions {
icon?: 'Comment' | 'Key' | 'Note' | 'Help' | 'NewParagraph' | 'Paragraph' | 'Insert';
contents?: string;
customData?: Record<string, unknown>;
flags?: ('invisible' | 'hidden' | 'print' | 'noZoom' | 'noRotate' | 'noView' | 'readOnly' | 'locked' | 'toggleNoView')[];
}
@@ -0,0 +1,27 @@
import { createContext, useContext, type ReactNode } from 'react';
export interface CommentAuthorValue {
displayName: string;
}
const defaultValue: CommentAuthorValue = { displayName: 'Guest' };
const CommentAuthorContext = createContext<CommentAuthorValue>(defaultValue);
export function CommentAuthorProvider({
children,
displayName = 'Guest',
}: {
children: ReactNode;
displayName?: string;
}) {
return (
<CommentAuthorContext.Provider value={{ displayName }}>
{children}
</CommentAuthorContext.Provider>
);
}
export function useCommentAuthor(): CommentAuthorValue {
return useContext(CommentAuthorContext);
}
@@ -85,6 +85,19 @@ export interface ViewerContextType {
toggleBookmarkSidebar: () => void;
isAttachmentSidebarVisible: boolean;
toggleAttachmentSidebar: () => void;
isCommentsSidebarVisible: boolean;
setCommentsSidebarVisible: (visible: boolean) => void;
toggleCommentsSidebar: () => void;
/** Request focus or highlight of a comment card in the sidebar (opens sidebar, then scrolls + flashes or focuses input). */
highlightCommentRequest: {
documentId: string;
pageIndex: number;
annotationId: string;
action: 'focus' | 'highlight';
} | null;
requestCommentFocus: (documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => void;
clearHighlightCommentRequest: () => void;
// Search interface visibility
isSearchInterfaceVisible: boolean;
@@ -170,6 +183,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isAttachmentSidebarVisible, setIsAttachmentSidebarVisible] = useState(false);
const [isCommentsSidebarVisible, setIsCommentsSidebarVisible] = useState(false);
const [highlightCommentRequest, setHighlightCommentRequest] = useState<{
documentId: string;
pageIndex: number;
annotationId: string;
action: 'focus' | 'highlight';
} | null>(null);
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
@@ -261,6 +281,31 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsAttachmentSidebarVisible(prev => !prev);
};
const setCommentsSidebarVisible = (visible: boolean) => {
setIsCommentsSidebarVisible(visible);
};
const toggleCommentsSidebar = () => {
setIsCommentsSidebarVisible(prev => !prev);
};
const requestCommentFocus = useCallback(
(documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => {
setIsCommentsSidebarVisible(true);
setHighlightCommentRequest({
documentId,
pageIndex,
annotationId,
action: hasContent ? 'highlight' : 'focus',
});
},
[]
);
const clearHighlightCommentRequest = useCallback(() => {
setHighlightCommentRequest(null);
}, []);
const searchInterfaceActions = {
open: () => setSearchInterfaceVisible(true),
close: () => setSearchInterfaceVisible(false),
@@ -392,6 +437,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
toggleBookmarkSidebar,
isAttachmentSidebarVisible,
toggleAttachmentSidebar,
isCommentsSidebarVisible,
setCommentsSidebarVisible,
toggleCommentsSidebar,
highlightCommentRequest,
requestCommentFocus,
clearHighlightCommentRequest,
// Search interface
isSearchInterfaceVisible,
@@ -2,8 +2,9 @@ import UploadIcon from '@mui/icons-material/Upload';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
/**
* File action icons for web builds
* Desktop builds override this with different icons
* File action icons for web builds.
* Desktop builds override this file via TypeScript path aliases to provide
* different icons (e.g. Save icon instead of Download, and a Save As icon).
*/
export function useFileActionIcons() {
return {
@@ -11,5 +12,8 @@ export function useFileActionIcons() {
download: DownloadOutlinedIcon,
uploadIconName: 'upload' as const,
downloadIconName: 'download' as const,
// Web builds do not expose a Save As icon — the button is hidden when this is undefined.
// Desktop builds override this file and return a real icon name.
saveAsIconName: undefined as string | undefined,
};
}
+25
View File
@@ -671,6 +671,31 @@
box-shadow: 0 0 0 0.5px var(--pdf-selection-ring);
}
/* Flash highlight for comment card (e.g. "View comment" from annotation menu) */
@keyframes comment-card-flash {
0% {
background-color: rgba(255, 235, 59, 0);
box-shadow: none;
}
20% {
background-color: rgba(255, 235, 59, 0.35);
box-shadow: 0 0 20px rgba(255, 235, 59, 0.5);
}
50% {
background-color: rgba(255, 235, 59, 0.25);
box-shadow: 0 0 15px rgba(255, 235, 59, 0.4);
}
100% {
background-color: rgba(255, 235, 59, 0);
box-shadow: none;
}
}
.comment-card-flash-highlight {
animation: comment-card-flash 1.5s ease-out;
border-radius: 8px;
}
/* Smooth transitions for theme switching */
* {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
+13 -2
View File
@@ -22,6 +22,9 @@ const DRAWING_TOOLS: AnnotationToolId[] = [
'inkHighlighter',
'text',
'note',
'textComment',
'insertText',
'replaceText',
'square',
'circle',
'line',
@@ -43,6 +46,9 @@ const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
'inkHighlighter',
'text',
'note',
'textComment',
'insertText',
'replaceText',
'square',
'circle',
'line',
@@ -75,7 +81,7 @@ const Annotate = (_props: BaseToolProps) => {
const viewerContext = useContext(ViewerContext);
const viewerContextRef = useRef(viewerContext);
useEffect(() => { viewerContextRef.current = viewerContext; }, [viewerContext]);
const { getZoomState, registerImmediateZoomUpdate, applyChanges, activeFileIndex, panActions } = useViewer();
const { getZoomState, registerImmediateZoomUpdate, applyChanges, activeFileIndex, panActions, setCommentsSidebarVisible } = useViewer();
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
@@ -312,6 +318,11 @@ const Annotate = (_props: BaseToolProps) => {
annotationApiRef?.current?.setAnnotationStyle?.(toolId, options);
annotationApiRef?.current?.activateAnnotationTool?.(toolId === 'stamp' ? 'stamp' : toolId, options);
// Auto-open comments sidebar when a comment tool is selected
if (['textComment', 'insertText', 'replaceText'].includes(toolId)) {
setCommentsSidebarVisible(true);
}
// Reset flag after a short delay
setTimeout(() => {
manualToolSwitch.current = false;
@@ -346,7 +357,7 @@ const Annotate = (_props: BaseToolProps) => {
const unsubscribe = annotationApiRef?.current?.onAnnotationEvent?.((event: AnnotationEvent) => {
if (event.type === 'create' && event.committed) {
const toolId = activeToolRef.current;
if (toolId === 'text' || toolId === 'note') {
if (['text', 'note', 'textComment', 'insertText', 'replaceText'].includes(toolId)) {
setActiveTool('select');
activeToolRef.current = 'select';
annotationApiRef?.current?.activateAnnotationTool?.('select');
@@ -209,6 +209,12 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
{ id: 'polygon', label: t('annotation.polygon', 'Polygon'), icon: 'change-history' },
];
const commentTools: { id: AnnotationToolId; label: string; icon: string }[] = [
{ id: 'textComment', label: t('annotation.comment', 'Comment'), icon: 'comment' },
{ id: 'insertText', label: t('annotation.insertText', 'Insert Text'), icon: 'add-comment' },
{ id: 'replaceText', label: t('annotation.replaceText', 'Replace Text'), icon: 'find-replace' },
];
const otherTools: { id: AnnotationToolId; label: string; icon: string }[] = [
{ id: 'text', label: t('annotation.text', 'Text box'), icon: 'text-fields' },
{ id: 'note', label: t('annotation.note', 'Note'), icon: 'sticky-note-2' },
@@ -778,6 +784,11 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
{renderToolButtons(shapeTools)}
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">{t('annotation.comments', 'Comments')}</Text>
{renderToolButtons(commentTools)}
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">{t('annotation.notesStamps', 'Notes & Stamps')}</Text>
{renderToolButtons(otherTools)}
+1
View File
@@ -42,6 +42,7 @@ export interface AppConfig {
error?: string;
isNewServer?: boolean;
isNewUser?: boolean;
username?: string | null;
defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean;
hideDisabledToolsGoogleDrive?: boolean;
@@ -2,8 +2,9 @@ import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
/**
* File action icons for desktop builds
* Overrides core implementation with desktop-appropriate icons
* File action icons for desktop builds.
* Overrides the core implementation with desktop-appropriate icons.
* The presence of `saveAsIconName` signals RightRail to show the Save As button.
*/
export function useFileActionIcons() {
return {
@@ -11,5 +12,8 @@ export function useFileActionIcons() {
download: SaveOutlinedIcon,
uploadIconName: 'folder-rounded' as const,
downloadIconName: 'save-rounded' as const,
// Returning this icon name causes RightRail to render the Save As button.
// On desktop, downloadFile() without a localPath shows a native save dialog.
saveAsIconName: 'save-as-rounded' as string | undefined,
};
}
@@ -38,12 +38,14 @@ export async function showSaveDialog(
try {
const { save } = await import("@tauri-apps/plugin-dialog");
// Derive the file type filter from the filename extension so the dialog
// doesn't force a .pdf extension when saving non-PDF outputs (e.g. .docx).
const ext = defaultFilename.split('.').pop()?.toLowerCase() ?? '';
const filters = ext ? [{ name: ext.toUpperCase(), extensions: [ext] }] : [];
const selectedPath = await save({
defaultPath: defaultDirectory ? `${defaultDirectory}/${defaultFilename}` : defaultFilename,
filters: [{
name: 'PDF',
extensions: ['pdf']
}],
filters,
title: 'Save As'
});
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Vendored
+1 -4
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
Vendored
+1 -2
View File
@@ -70,11 +70,10 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell