Compare commits

..
Author SHA1 Message Date
Connor Yoh 97f4b5c433 Config becomes account when enableLogin 2025-10-02 16:46:12 +01:00
99 changed files with 1490 additions and 3449 deletions
+4 -4
View File
@@ -122,11 +122,11 @@ Stirling-PDF currently supports 40 languages!
| Catalan (Català) (ca_CA) | ![38%](https://geps.dev/progress/38) |
| Croatian (Hrvatski) (hr_HR) | ![35%](https://geps.dev/progress/35) |
| Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) |
| Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) |
| Danish (Dansk) (da_DK) | ![35%](https://geps.dev/progress/35) |
| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![95%](https://geps.dev/progress/95) |
| French (Français) (fr_FR) | ![96%](https://geps.dev/progress/96) |
| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) |
| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) |
| Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) |
@@ -136,14 +136,14 @@ Stirling-PDF currently supports 40 languages!
| Italian (Italiano) (it_IT) | ![97%](https://geps.dev/progress/97) |
| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) |
| Korean (한국어) (ko_KR) | ![39%](https://geps.dev/progress/39) |
| Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) |
| Norwegian (Norsk) (no_NB) | ![37%](https://geps.dev/progress/37) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) |
| Portuguese (Português) (pt_PT) | ![39%](https://geps.dev/progress/39) |
| Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) |
| Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) |
| Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![43%](https://geps.dev/progress/43) |
| Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) |
| Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) |
| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) |
@@ -258,6 +258,12 @@ public class AppConfig {
return false;
}
@Bean(name = "GoogleDriveEnabled")
@Profile("default")
public boolean googleDriveEnabled() {
return false;
}
@Bean(name = "license")
@Profile("default")
public String licenseType() {
@@ -530,6 +530,7 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class CustomMetadata {
@@ -548,6 +549,26 @@ public class ApplicationProperties {
: producer;
}
}
@Data
public static class GoogleDrive {
private boolean enabled;
private String clientId;
private String apiKey;
private String appId;
public String getClientId() {
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
}
public String getApiKey() {
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
}
public String getAppId() {
return appId == null || appId.trim().isEmpty() ? "" : appId;
}
}
}
@Data
@@ -109,6 +109,22 @@ class ApplicationPropertiesLogicTest {
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
@@ -98,6 +98,11 @@ public class ConfigController {
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("GoogleDriveEnabled")) {
configData.put(
"GoogleDriveEnabled",
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -76,6 +76,11 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
googleDrive:
enabled: false
clientId: ''
apiKey: ''
appId: ''
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
@@ -422,6 +422,10 @@
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
</div>
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
</div>
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
</div>
</div>
<div class="selected-files flex-wrap"></div>
@@ -439,4 +443,16 @@
</div>
</div>
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
<div th:if="${@GoogleDriveEnabled == true}" >
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
<script th:inline="javascript">
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
</script>
</div>
</th:block>
@@ -12,6 +12,7 @@ import org.springframework.core.annotation.Order;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@@ -54,6 +55,19 @@ public class EEAppConfig {
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
@Profile("security")
@Bean(name = "GoogleDriveEnabled")
@Primary
public boolean googleDriveEnabled() {
return runningProOrHigher()
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
}
@Bean(name = "GoogleDriveConfig")
public GoogleDrive googleDriveConfig() {
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
}
// TODO: Remove post migration
@SuppressWarnings("deprecation")
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
+247 -239
View File
@@ -8,15 +8,14 @@ Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This
Stirling-PDF is built using:
- Spring Boot (Backend API)
- React + TypeScript + Vite (Frontend V2)
- Mantine UI + TailwindCSS (UI Framework)
- PDFBox (PDF manipulation)
- LibreOffice (Document conversion)
- qpdf (PDF processing)
- PDF.js (Client-side PDF rendering)
- Embedded-PDF (PDF viewer component)
- Spring Boot + Thymeleaf
- PDFBox
- LibreOffice
- qpdf
- HTML, CSS, JavaScript
- Docker
- PDF.js
- PDF-LIB.js
- Lombok
## 3. Development Environment Setup
@@ -25,9 +24,8 @@ Stirling-PDF is built using:
- Docker
- Git
- Java JDK 17 or later (JDK 21 recommended)
- Java JDK 17 or later
- Gradle 7.0 or later (Included within the repo)
- Node.js 18+ and npm (for frontend development)
### Setup Steps
@@ -40,8 +38,8 @@ Stirling-PDF is built using:
2. Install Docker and JDK17 if not already installed.
3. Install a recommended IDE:
- **VSCode** (recommended for frontend)
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
1. Open VS Code.
2. When prompted, install the recommended extensions.
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
@@ -51,15 +49,13 @@ Stirling-PDF is built using:
```
4. Install the required extensions from the list.
- **IntelliJ IDEA** (recommended for backend)
- **Eclipse** (alternative for backend)
4. Lombok Setup
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
## 4. Project Structure
@@ -72,21 +68,10 @@ Stirling-PDF/
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
├── frontend/ # React frontend application (V2)
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── tools/ # PDF tool implementations
│ │ ├── contexts/ # React contexts (FileContext, etc.)
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API and processing services
│ │ └── i18n.ts # Internationalization config
│ ├── public/
│ │ └── locales/ # Translation JSON files
│ └── package.json
├── images/ # Image assets
├── pipeline/ # Pipeline-related files (generated at runtime)
├── scripts/ # Utility scripts
├── src/ # Backend source code
├── src/ # Source code
│ ├── main/
│ │ ├── java/
│ │ │ └── stirling/
@@ -94,14 +79,16 @@ Stirling-PDF/
│ │ │ └── SPDF/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ │ ├── api/ # REST API endpoints
│ │ │ │ └── web/ # Web controllers
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ └── static/ # Legacy static assets
│ │ ├── static/
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/
│ └── test/
│ └── java/
│ └── stirling/
@@ -154,7 +141,7 @@ services:
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
environment:
DOCKER_ENABLE_SECURITY: "true"
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
@@ -183,7 +170,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DOCKER_ENABLE_SECURITY=true # or false to disable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project with Gradle:
@@ -209,7 +196,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
For the fat version (with login and security features enabled):
```bash
export DOCKER_ENABLE_SECURITY=true
export DISABLE_ADDITIONAL_FEATURES=false
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
@@ -237,50 +224,38 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
### Full Testing with Docker
1. Build and run the Docker container per the above instructions
1. Build and run the Docker container per the above instructions:
2. Access the application at `http://localhost:8080` and manually test all features developed.
### Local Testing (Frontend and Backend)
### Local Testing (Java and UI Components)
For quick iterations and development, you can run the frontend and backend separately:
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
#### Backend Development
- Java backend logic
- RESTful API endpoints
- JavaScript functionality
- User interface components and styling
- Thymeleaf templates
1. Run the backend:
To run Stirling-PDF locally:
1. Compile and run the project using built-in IDE methods or by running:
```bash
./gradlew bootRun
```
2. The backend API will be available at `http://localhost:8080`
2. Access the application at `http://localhost:8080` in your web browser.
3. API documentation is available at `http://localhost:8080/swagger-ui/index.html`
3. Manually test the features you're working on through the UI.
#### Frontend Development
1. Install dependencies (first time only):
```bash
cd frontend
npm install
```
2. Start the development server:
```bash
npm run dev
```
3. The frontend will be available at `http://localhost:5173`
4. Vite automatically proxies API calls from `/api/*` to the backend at `localhost:8080`
4. For API changes, use tools like Postman or curl to test endpoints directly.
Important notes:
- Frontend requires the backend to be running for full functionality
- Hot module replacement (HMR) enables instant updates during development
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
## 7. Contributing
@@ -332,170 +307,112 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
Refer to the main README for a full list of customization options.
## 10. Frontend Development (V2)
## 10. Language Translations
### Architecture Overview
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
The V2 frontend is designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
### Key Components
#### FileContext - Central State Management
**Location**: `frontend/src/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
- **IndexedDB persistence**: File storage with thumbnail caching
- **Preview system**: Tools can preview results without context pollution
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
#### Processing Services
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
- **fileStorage**: IndexedDB with LRU cache management
### Tool Development
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
});
```bash
/scripts/replace_translation_line.sh
```
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
This script helps you make consistent replacements across language files.
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_',
});
When contributing translations:
1. Use the helper script for multi-file changes.
2. Ensure all language files are updated consistently.
3. The PR checks will verify consistency in language file updates.
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
## Code examples
### Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `app/core/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
});
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
### Adding a New Tool
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
See [ADDING_TOOLS.md](../ADDING_TOOLS.md) for a complete guide to creating new PDF tools.
### Internationalization
Translations are stored in JSON files at `frontend/public/locales/{language-code}/translation.json`.
To use translations in React components:
```typescript
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return (
<div>
<h1>{t('myTool.title')}</h1>
<p>{t('myTool.description')}</p>
</div>
);
}
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
```
See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new languages.
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
## 11. Backend Development
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
### Adding a New API Endpoint
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API)
1. **Create a New Controller:**
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/api` directory.
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag` and `@Operation`.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/pdf")
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@PostMapping("/new-feature")
@Operation(summary = "New Feature", description = "This is a new feature endpoint. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> newFeature(
@RequestPart("fileInput") MultipartFile file,
@RequestParam("param1") String param1) {
// Process PDF
byte[] result = processFile(file, param1);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=output.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(result);
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
}
}
```
2. **Define the Service Layer:** (Optional but recommended)
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
2. **Define the Service Layer:** (Not required but often useful)
- Create a new service class in the `app/core/src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
@@ -506,76 +423,167 @@ See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new
@Service
public class NewFeatureService {
public byte[] processFile(MultipartFile file, String param1) {
public String getNewFeatureData() {
// Implement business logic here
return processedBytes;
return "New Feature Data";
}
}
```
3. **Integrate the Service with the Controller:**
2b. **Integrate the Service with the Controller:**
- Autowire the service class in the controller and use it to handle the API request.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.SPDF.service.NewFeatureService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return newFeatureService.getNewFeatureData();
}
}
```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `app/core/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
@RestController
@RequestMapping("/api/v1/pdf")
public class NewFeatureController {
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@Autowired
private NewFeatureService newFeatureService;
@PostMapping("/new-feature")
public ResponseEntity<byte[]> newFeature(
@RequestPart("fileInput") MultipartFile file,
@RequestParam("param1") String param1) {
byte[] result = newFeatureService.processFile(file, param1);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=output.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(result);
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
}
}
```
### Multi-File Endpoints
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `app/core/src/main/resources/templates/fragments/navbar.html` file.
For tools that process multiple files in one request:
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
```java
@PostMapping("/merge")
public ResponseEntity<byte[]> mergePdfs(
@RequestPart("fileInput") MultipartFile[] files) {
## Adding New Translations to Existing Language Files in Stirling-PDF
// Process all files together
byte[] merged = mergeService.mergeFiles(files);
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=merged.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(merged);
}
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `app/core/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
### 2. Add New Translation Entries
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
you might add:
```properties
pdfSplitter.title=PDF Splitter
pdfSplitter.description=Split your PDF into multiple documents
pdfSplitter.button.split=Split PDF
pdfSplitter.input.pages=Enter page numbers to split
```
## 12. Best Practices
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
### Frontend
- Always use FileContext for file operations
- Implement proper cleanup for PDF.js documents and blob URLs
- Use the `useToolOperation` hook for consistent tool behavior
- Follow TypeScript strict mode guidelines
- Test with large files (100MB+) to ensure memory efficiency
### 3. Use Translations in Thymeleaf Templates
### Backend
- Use PDFBox for PDF manipulation
- Implement proper error handling and logging
- Add Swagger documentation to all API endpoints
- Use service layer for business logic
- Follow Spring Boot best practices
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
### General
- Write clear commit messages
- Update documentation for any API changes
- Test in Docker before submitting PRs
- Run `./gradlew spotlessApply` to format code
- Ensure all tests pass with `./test.sh`
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
+27 -149
View File
@@ -8,66 +8,36 @@
Fork Stirling-PDF and create a new branch out of `main`.
## Add Language to i18n Configuration
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
Edit the file: [frontend/src/i18n.ts](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/src/i18n.ts)
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
Add your language to the `supportedLanguages` object. For example, to add Polish:
```typescript
export const supportedLanguages = {
'en': 'English',
'en-GB': 'English (UK)',
// ... other languages ...
'pl-PL': 'Polski', // Add your language here
};
For example, to add Polish, you would add:
```html
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
```
If your language uses right-to-left (RTL) text direction, also add it to the `rtlLanguages` array:
The `data-bs-language-code` is the code used to reference the file in the next step.
```typescript
export const rtlLanguages = ['ar-AR', 'fa-IR', 'pl-PL']; // Add if RTL
```
### Add Language Property File
## Create Translation Directory
Start by copying the existing English property file:
Create a new directory for your language in `frontend/public/locales/`. For Polish, this would be:
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
```bash
mkdir -p frontend/public/locales/pl-PL
```
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
## Add Translation File
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
Start by copying the existing English (UK) translation file:
- [frontend/public/locales/en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.json)
Copy and rename it to `frontend/public/locales/{your-language-code}/translation.json`. In the Polish example:
```bash
cp frontend/public/locales/en-GB/translation.json frontend/public/locales/pl-PL/translation.json
```
Then translate all entries within that JSON file. The file uses nested JSON structure like:
```json
{
"addPageNumbers": {
"title": "Add Page Numbers",
"submit": "Add Page Numbers",
"error": {
"failed": "Add page numbers operation failed"
}
}
}
```
If you do not have a Java IDE, I am happy to verify that the changes work once you raise the PR (but I won't be able to verify the translations themselves).
## Handling Untranslatable Strings
Sometimes, certain strings may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
Sometimes, certain strings in the properties file may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
For example, if the English string for "error" does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
For example, if the English string `error=Error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
```toml
[pl_PL]
@@ -80,119 +50,27 @@ ignore = [
## Add New Translation Tags
> [!IMPORTANT]
> If you add any new translation tags, they must first be added to the `frontend/public/locales/en-GB/translation.json` file. This ensures consistency across all language files.
> If you add any new translation tags, they must first be added to the `messages_en_GB.properties` file. This ensures consistency across all language files.
- New translation tags **must be added** to the `en-GB` translation file to maintain a reference for other languages.
- After adding the new tags to the en-GB file, add and translate them in the respective language file (e.g., `pl-PL/translation.json`).
- New translation tags **must be added** to the `messages_en_GB.properties` file to maintain a reference for other languages.
- After adding the new tags to `messages_en_GB.properties`, add and translate them in the respective language file (e.g., `messages_pl_PL.properties`).
Make sure to place the entry under the correct language section. This helps maintain the accuracy of translation progress statistics and ensures that the translation tool or scripts do not misinterpret the completion rate.
## Testing Your Translation
### Use this code to perform a local check
### Start the development server
#### Windows command
1. Start the frontend development server:
```bash
cd frontend
npm run dev
```
```powershell
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
2. The language selector should now include your new language
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
```
3. Select your language from the dropdown and verify all translations appear correctly
## Summary Checklist
When adding a new language, you need to update:
- [ ] `frontend/src/i18n.ts` - Add to supportedLanguages (and rtlLanguages if needed)
- [ ] `frontend/public/locales/{language-code}/translation.json` - Create and translate
- [ ] `scripts/ignore_translation.toml` - Add untranslatable strings if needed
Then make a Pull Request (PR) into `main` for others to use!
If you do not have a Node.js environment, we are happy to verify that the changes work once you raise the PR (but we won't be able to verify the translations themselves).
## Translation Guidelines
- **Consistency**: Keep terminology consistent throughout the translation
- **Context**: Consider the UI context when translating (e.g., button labels should be concise)
- **Formatting**: Preserve placeholders like `{n}` or `{{count}}` in translations
- **Testing**: Test your translations in the frontend interface
- **RTL Languages**: If your language uses RTL, ensure you add it to the rtlLanguages array
## Advanced: Translation Management Scripts
For translators working on large translation files, Python scripts are available in `scripts/translations/` to help manage the workflow.
### Finding Untranslated Strings
To see which strings still need translation:
#### Linux command
```bash
# Check translation status for your language
python scripts/translations/translation_analyzer.py --language pl-PL --summary
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
# See detailed list of missing translations
python scripts/translations/translation_analyzer.py --language pl-PL --missing-only
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
```
### Extracting Untranslated Strings
To extract only the strings that need translation into a separate file:
```bash
# Extract to a compact JSON file
python scripts/translations/compact_translator.py pl-PL --output to_translate.json
```
This creates a file with just the untranslated entries:
```json
{
"addPageNumbers.title": "Add Page Numbers",
"compress.header": "Compress PDF",
"merge.submit": "Merge PDFs"
}
```
### Translating the Extracted File
Open `to_translate.json` and translate the values while keeping the keys unchanged:
```json
{
"addPageNumbers.title": "Dodaj numery stron",
"compress.header": "Kompresuj PDF",
"merge.submit": "Połącz pliki PDF"
}
```
### Merging Translations Back
After translating, merge your translations back into the main file:
```bash
# Apply your translations
python scripts/translations/translation_merger.py pl-PL apply-translations --translations-file to_translate.json
# Verify the result
python scripts/translations/translation_analyzer.py --language pl-PL --summary
```
### Validating Your Work
Before submitting, validate your translation file:
```bash
# Check for JSON syntax errors
python scripts/translations/json_validator.py frontend/public/locales/pl-PL/translation.json
# Check for missing placeholders
python scripts/translations/validate_placeholders.py --language pl-PL
# Check for structural issues
python scripts/translations/validate_json_structure.py --language pl-PL
```
**Note**: These scripts require Python 3.7+ to be installed. See `scripts/translations/README.md` for detailed documentation.
+1 -8
View File
@@ -50,14 +50,7 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
- **Custom Ports**: Modify port mappings in docker-compose files
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
## Development vs Production
- **Development**: Keep backend port 8080 exposed for debugging
- **Production**: Remove backend port exposure, use only frontend proxy
- **Production**: Remove backend port exposure, use only frontend proxy
-3
View File
@@ -47,9 +47,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
@@ -44,9 +44,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
-3
View File
@@ -46,9 +46,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
+2 -28
View File
@@ -1,18 +1,9 @@
// @ts-check
import eslint from '@eslint/js';
import globals from "globals";
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
];
const nodeGlobs = [
'scripts/**/*.{js,ts,mjs}',
'*.config.{js,ts,mjs}',
];
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
@@ -24,6 +15,7 @@ export default defineConfig(
},
{
rules: {
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
@@ -46,23 +38,5 @@ export default defineConfig(
},
],
},
},
// Config for browser scripts
{
files: srcGlobs,
languageOptions: {
globals: {
...globals.browser,
}
}
},
// Config for node scripts
{
files: nodeGlobs,
languageOptions: {
globals: {
...globals.node,
}
}
},
}
);
+4 -98
View File
@@ -41,7 +41,6 @@
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -54,7 +53,6 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -67,10 +65,6 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
@@ -1766,19 +1760,6 @@
"mlly": "^1.7.4"
}
},
"node_modules/@iconify/utils/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2014,28 +1995,6 @@
"react": "^18.x || ^19.x"
}
},
"node_modules/@maxim_mazurok/gapi.client.discovery-v1": {
"version": "0.4.20200806",
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz",
"integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/gapi.client": "*",
"@types/gapi.client.discovery-v1": "*"
}
},
"node_modules/@maxim_mazurok/gapi.client.drive-v3": {
"version": "0.1.20250930",
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20250930.tgz",
"integrity": "sha512-zNR7HtaFl2Pvf8Ck2zP8cppUst7ouY2isKn7hrGf6hQ4/0ULsu19qMRSQgRb0HxBYcGjak7kGK4pZI4a2z4CWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/gapi.client": "*",
"@types/gapi.client.discovery-v1": "*"
}
},
"node_modules/@mui/core-downloads-tracker": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
@@ -3622,54 +3581,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi": {
"version": "0.0.47",
"resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz",
"integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi.client": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz",
"integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi.client.discovery-v1": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz",
"integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@maxim_mazurok/gapi.client.discovery-v1": "latest"
}
},
"node_modules/@types/gapi.client.drive-v3": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz",
"integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@maxim_mazurok/gapi.client.drive-v3": "latest"
}
},
"node_modules/@types/google.accounts": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
"integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/google.picker": {
"version": "0.0.51",
"resolved": "https://registry.npmjs.org/@types/google.picker/-/google.picker-0.0.51.tgz",
"integrity": "sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@@ -6504,9 +6415,10 @@
}
},
"node_modules/globals": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9993,12 +9905,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/signature_pad": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.1.tgz",
"integrity": "sha512-BT5JJygS5BS0oV+tffPRorIud6q17bM7v/1LdQwd0o6mTqGoI25yY1NjSL99OqkekWltS4uon6p52Y8j1Zqu7g==",
"license": "MIT"
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
-6
View File
@@ -37,7 +37,6 @@
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -50,7 +49,6 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -106,10 +104,6 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
+8 -32
View File
@@ -1,10 +1,9 @@
{
"unsavedChanges": "You have unsaved changes to your PDF.",
"areYouSure": "Are you sure you want to leave?",
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
"unsavedChangesTitle": "Unsaved Changes",
"keepWorking": "Keep Working",
"discardChanges": "Discard & Leave",
"applyAndContinue": "Save & Leave",
"discardChanges": "Discard Changes",
"applyAndContinue": "Apply & Continue",
"exportAndContinue": "Export & Continue",
"language": {
"direction": "ltr"
@@ -66,8 +65,6 @@
"save": "Save",
"saveToBrowser": "Save to Browser",
"download": "Download",
"pin": "Pin",
"unpin": "Unpin",
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
"undo": "Undo",
"moreOptions": "More Options",
@@ -259,16 +256,6 @@
"name": "Save form inputs",
"help": "Enable to store previously used inputs for future runs"
},
"general": {
"title": "General",
"description": "Configure general application preferences.",
"autoUnzip": "Auto-unzip API responses",
"autoUnzipDescription": "Automatically extract files from ZIP responses",
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
"autoUnzipFileLimit": "Auto-unzip file limit",
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
},
"hotkeys": {
"title": "Keyboard Shortcuts",
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
@@ -283,8 +270,7 @@
"capturing": "Press keys… (Esc to cancel)",
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut",
"noShortcut": "No shortcut set"
"shortcut": "Shortcut"
}
},
"changeCreds": {
@@ -561,7 +547,7 @@
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colours/Contrast",
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
@@ -1819,16 +1805,8 @@
"placeholder": "Enter your full name"
},
"instructions": {
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature."
"title": "How to add signature"
},
"mode": {
"move": "Move Signature",
"place": "Place Signature"
},
"updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
"deactivate": "Stop Placing Signatures",
"results": {
@@ -2813,9 +2791,8 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
@@ -3208,7 +3185,6 @@
"lastModified": "Last Modified",
"toolChain": "Tools Applied",
"restore": "Restore",
"unzip": "Unzip",
"searchFiles": "Search files...",
"recent": "Recent",
"localFiles": "Local Files",
@@ -562,7 +562,7 @@
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colors/Contrast",
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
@@ -1712,9 +1712,8 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
+19 -22
View File
@@ -6,7 +6,6 @@ import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
@@ -42,27 +41,25 @@ export default function App() {
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<ErrorBoundary>
<PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>
-25
View File
@@ -9,8 +9,6 @@ import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
import { loadScript } from '../utils/scriptLoader';
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -86,29 +84,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
};
}, []);
// Preload Google Drive scripts if configured
useEffect(() => {
if (isGoogleDriveConfigured()) {
// Load scripts in parallel without blocking
Promise.all([
loadScript({
src: 'https://apis.google.com/js/api.js',
id: 'gapi-script',
async: true,
defer: true,
}),
loadScript({
src: 'https://accounts.google.com/gsi/client',
id: 'gis-script',
async: true,
defer: true,
}),
]).catch((error) => {
console.warn('Failed to preload Google Drive scripts:', error);
});
}
}, []);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
@@ -1,8 +1,7 @@
import React, { useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import React, { useRef, useState, useCallback } from 'react';
import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
import { ColorSwatchButton } from './ColorPicker';
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
interface DrawingCanvasProps {
selectedColor: string;
@@ -12,7 +11,6 @@ interface DrawingCanvasProps {
onPenSizeChange: (size: number) => void;
onPenSizeInputChange: (input: string) => void;
onSignatureDataChange: (data: string | null) => void;
onDrawingComplete?: () => void;
disabled?: boolean;
width?: number;
height?: number;
@@ -29,253 +27,411 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
onDrawingComplete,
disabled = false,
width = 400,
height = 150,
modalWidth = 800,
modalHeight = 400,
additionalButtons
}) => {
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const visibleModalCanvasRef = useRef<HTMLCanvasElement>(null);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
const [isDrawing, setIsDrawing] = useState(false);
const [isModalDrawing, setIsModalDrawing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
padRef.current = new SignaturePad(canvas, {
penColor: selectedColor,
minWidth: penSize * 0.5,
maxWidth: penSize * 2.5,
throttle: 10,
minDistance: 5,
velocityFilterWeight: 0.7,
});
// Drawing functions for main canvas
const startDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || disabled) return;
setIsDrawing(true);
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
}
};
}, [disabled, selectedColor, penSize]);
const openModal = () => {
// Clear pad ref so it reinitializes
if (padRef.current) {
padRef.current.off();
padRef.current = null;
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !canvasRef.current || disabled) return;
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
setModalOpen(true);
};
}, [isDrawing, disabled]);
const trimCanvas = (canvas: HTMLCanvasElement): string => {
const ctx = canvas.getContext('2d');
if (!ctx) return canvas.toDataURL('image/png');
const stopDrawing = useCallback(() => {
if (!isDrawing || disabled) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
setIsDrawing(false);
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
// Save canvas as signature data
if (canvasRef.current) {
const dataURL = canvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
}
}, [isDrawing, disabled, onSignatureDataChange]);
// Find bounds of non-transparent pixels
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
if (alpha > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
// Modal canvas drawing functions
const startModalDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!visibleModalCanvasRef.current || !modalCanvasRef.current) return;
setIsModalDrawing(true);
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both the visible modal canvas and hidden canvas
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
}
});
}, [selectedColor, penSize]);
const drawModal = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isModalDrawing || !visibleModalCanvasRef.current || !modalCanvasRef.current) return;
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both canvases
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
});
}, [isModalDrawing]);
const stopModalDrawing = useCallback(() => {
if (!isModalDrawing) return;
setIsModalDrawing(false);
// Sync the canvases and update signature data (only when drawing stops)
if (modalCanvasRef.current) {
const dataURL = modalCanvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
// Also update the small canvas display
if (canvasRef.current) {
const smallCtx = canvasRef.current.getContext('2d');
if (smallCtx) {
const img = new Image();
img.onload = () => {
smallCtx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
smallCtx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
};
img.src = dataURL;
}
}
}
}, [isModalDrawing]);
const trimWidth = maxX - minX + 1;
const trimHeight = maxY - minY + 1;
// Clear canvas functions
const clearCanvas = useCallback(() => {
if (!canvasRef.current || disabled) return;
// Create trimmed canvas
const trimmedCanvas = document.createElement('canvas');
trimmedCanvas.width = trimWidth;
trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext('2d');
if (trimmedCtx) {
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// Also clear the modal canvas if it exists
if (modalCanvasRef.current) {
const modalCtx = modalCanvasRef.current.getContext('2d');
if (modalCtx) {
modalCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
}
}
onSignatureDataChange(null);
}
}, [disabled]);
const clearModalCanvas = useCallback(() => {
// Clear both modal canvases (visible and hidden)
if (modalCanvasRef.current) {
const hiddenCtx = modalCanvasRef.current.getContext('2d');
if (hiddenCtx) {
hiddenCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
}
}
return trimmedCanvas.toDataURL('image/png');
};
if (visibleModalCanvasRef.current) {
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
if (visibleCtx) {
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
}
}
const closeModal = () => {
if (padRef.current && !padRef.current.isEmpty()) {
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
onSignatureDataChange(trimmedPng);
// Also clear the main canvas and signature data
if (canvasRef.current) {
const mainCtx = canvasRef.current.getContext('2d');
if (mainCtx) {
mainCtx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
}
}
// Update preview canvas with proper aspect ratio
onSignatureDataChange(null);
}, []);
const saveModalSignature = useCallback(() => {
if (!modalCanvasRef.current) return;
const dataURL = modalCanvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
// Copy to small canvas for display
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
const img = new Image();
img.onload = () => {
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
const scale = Math.min(
previewCanvasRef.current.width / img.width,
previewCanvasRef.current.height / img.height
);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
}
}
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
};
img.src = trimmedPng;
img.src = dataURL;
}
}
if (onDrawingComplete) {
onDrawingComplete();
setIsModalOpen(false);
}, []);
const openModal = useCallback(() => {
setIsModalOpen(true);
// Copy content to modal canvas after a brief delay
setTimeout(() => {
if (visibleModalCanvasRef.current && modalCanvasRef.current) {
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
if (visibleCtx) {
visibleCtx.strokeStyle = selectedColor;
visibleCtx.lineWidth = penSize;
visibleCtx.lineCap = 'round';
visibleCtx.lineJoin = 'round';
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
visibleCtx.drawImage(modalCanvasRef.current, 0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
}
}
}
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(false);
};
}, 300);
}, [selectedColor, penSize]);
const clear = () => {
if (padRef.current) {
padRef.current.clear();
}
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
// Initialize canvas settings whenever color or pen size changes
React.useEffect(() => {
const updateCanvas = (canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
}
onSignatureDataChange(null);
};
};
const updatePenColor = (color: string) => {
if (padRef.current) {
padRef.current.penColor = color;
}
};
const updatePenSize = (size: number) => {
if (padRef.current) {
padRef.current.minWidth = size * 0.8;
padRef.current.maxWidth = size * 1.2;
}
};
updateCanvas(canvasRef.current);
updateCanvas(modalCanvasRef.current);
updateCanvas(visibleModalCanvasRef.current);
}, [selectedColor, penSize]);
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<Group justify="space-between">
<Text fw={500}>Draw your signature</Text>
<Group gap="lg">
<div>
<Text size="sm" fw={500} mb="xs" ta="center">Color</Text>
<Group justify="center">
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Group>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
disabled={disabled}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
<div style={{ paddingTop: '24px' }}>
<Button
variant="light"
size="compact-sm"
onClick={openModal}
disabled={disabled}
>
Expand
</Button>
</div>
</Group>
</Group>
<canvas
ref={previewCanvasRef}
ref={canvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
cursor: disabled ? 'default' : 'crosshair',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
</Text>
<Group justify="space-between">
<div>
{additionalButtons}
</div>
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={clearCanvas}
disabled={disabled}
>
Clear
</Button>
</Group>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
{/* Hidden canvas for modal synchronization */}
<canvas
ref={modalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{ display: 'none' }}
/>
{/* Modal for larger signature canvas */}
<Modal
opened={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Draw Your Signature"
size="xl"
centered
>
<Stack gap="md">
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<Popover
opened={colorPickerOpen}
onChange={setColorPickerOpen}
position="bottom-start"
withArrow
withinPortal={false}
>
<Popover.Target>
<div>
<ColorSwatchButton
color={selectedColor}
onClick={() => setColorPickerOpen(!colorPickerOpen)}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={(color) => {
onColorSwatchClick();
updatePenColor(color);
}}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
/>
</Popover.Dropdown>
</Popover>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={(size) => {
onPenSizeChange(size);
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</div>
{/* Color and Pen Size picker */}
<Paper withBorder p="sm">
<Group gap="lg" align="flex-end">
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</Group>
</Paper>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
<Paper withBorder p="md">
<canvas
ref={visibleModalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'crosshair',
backgroundColor: '#ffffff',
width: '100%',
maxWidth: `${modalWidth}px`,
height: 'auto',
}}
onMouseDown={startModalDrawing}
onMouseMove={drawModal}
onMouseUp={stopModalDrawing}
onMouseLeave={stopModalDrawing}
/>
</Paper>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
<Group justify="space-between">
<Button
variant="subtle"
color="red"
onClick={clearModalCanvas}
>
Clear Canvas
</Button>
<Button onClick={closeModal}>
Done
</Button>
</div>
<Group gap="sm">
<Button
variant="subtle"
onClick={() => setIsModalOpen(false)}
>
Cancel
</Button>
<Button
onClick={saveModalSignature}
>
Save Signature
</Button>
</Group>
</Group>
</Stack>
</Modal>
</>
);
};
export default DrawingCanvas;
export default DrawingCanvas;
@@ -48,7 +48,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{hint || t('sign.image.hint', 'Upload an image of your signature')}
{hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')}
</Text>
</Stack>
);
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from './ColorPicker';
interface TextInputWithFontProps {
text: string;
@@ -10,8 +9,6 @@ interface TextInputWithFontProps {
onFontSizeChange: (size: number) => void;
fontFamily: string;
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
@@ -24,8 +21,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = '#000000',
onTextColorChange,
disabled = false,
label,
placeholder
@@ -33,7 +28,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// Sync font size input with prop changes
useEffect(() => {
@@ -48,7 +42,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{ value: 'Georgia', label: 'Georgia' },
];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48'];
return (
<Stack gap="sm">
@@ -72,101 +66,61 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
allowDeselect={false}
/>
{/* Font Size and Color */}
<Group grow>
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
{/* Font Size */}
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-72)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 200) {
onFontSizeChange(size);
}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 200) {
setFontSizeInput(fontSize.toString());
} else {
onFontSizeChange(size);
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Text Color Picker */}
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
rightSection={
<Box
style={{
width: 24,
height: 24,
backgroundColor: textColor,
border: '1px solid #ccc',
borderRadius: 4,
cursor: disabled ? 'default' : 'pointer'
}}
/>
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 72) {
onFontSizeChange(size);
}
/>
</Box>
)}
</Group>
{/* Color Picker Modal */}
{onTextColorChange && (
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor}
onColorChange={onTextColorChange}
/>
)}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 72) {
setFontSizeInput(fontSize.toString());
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Stack>
);
};
@@ -15,7 +15,7 @@ interface AddFileCardProps {
const AddFileCard = ({
onFileSelect,
accept,
accept = "*/*",
multiple = true
}: AddFileCardProps) => {
const { t } = useTranslation();
@@ -34,9 +34,9 @@
.header {
height: 2.25rem;
border-radius: 0.0625rem 0.0625rem 0 0;
display: flex;
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
justify-content: space-between;
padding: 0 6px;
user-select: none;
background: var(--bg-toolbar);
@@ -86,23 +86,14 @@
}
.headerIndex {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.04em;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.headerIconButton {
.kebab {
justify-self: end;
color: #FFFFFF !important;
}
@@ -225,11 +216,6 @@
color: rgba(0, 0, 0, 0.35);
}
.pinned {
color: #FFC107 !important;
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
@@ -398,4 +384,4 @@
.addFileSubtext {
font-size: 0.875rem;
opacity: 0.8;
}
}
@@ -3,7 +3,7 @@ import {
Text, Center, Box, LoadingOverlay, Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
@@ -37,7 +37,6 @@ const FileEditor = ({
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions } = useFileActions();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
@@ -288,7 +287,7 @@ const FileEditor = ({
// File operations using context
const handleCloseFile = useCallback((fileId: FileId) => {
const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
@@ -310,48 +309,6 @@ const FileEditor = ({
}
}, [activeStirlingFileStubs, selectors, _setStatus]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await actions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: 'success',
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500
});
} else {
alert({
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
body: result.errors.join('\n'),
expandable: true,
durationMs: 3500
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
}
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
if (record) {
@@ -391,7 +348,7 @@ const FileEditor = ({
<Box pos="relative" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md">
<Box p="md" pt="xl">
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
@@ -467,12 +424,11 @@ const FileEditor = ({
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
@@ -490,7 +446,7 @@ const FileEditor = ({
onSelectFiles={handleLoadFromStorage}
/>
</Box>
</Dropzone>
);
@@ -1,17 +1,15 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { alert } from '../toast';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import CloseIcon from '@mui/icons-material/Close';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '../../types/fileContext';
import { zipFileService } from '../../services/zipFileService';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
@@ -29,12 +27,11 @@ interface FileEditorThumbnailProps {
selectedFiles: FileId[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
@@ -44,11 +41,10 @@ const FileEditorThumbnail = ({
index,
selectedFiles,
onToggleFile,
onCloseFile,
onDeleteFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
@@ -68,9 +64,6 @@ const FileEditorThumbnail = ({
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const pageCount = file.processedFile?.totalPages || 0;
const handleRef = useRef<HTMLSpanElement | null>(null);
@@ -258,60 +251,18 @@ const FileEditorThumbnail = ({
{index + 1}
</div>
{/* Action buttons group */}
<div className={styles.headerActions}>
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
<ActionIcon
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</ActionIcon>
</Tooltip>
{/* Download icon */}
<Tooltip label={t('download', 'Download')}>
<ActionIcon
aria-label={t('download', 'Download')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
}}
>
<DownloadOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Actions overlay */}
@@ -336,7 +287,7 @@ const FileEditorThumbnail = ({
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
</button>
@@ -348,28 +299,18 @@ const FileEditorThumbnail = ({
<span>{t('download', 'Download')}</span>
</button>
{isZipFile && onUnzipFile && (
<button
className={styles.actionRow}
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<UnarchiveIcon fontSize="small" />
<span>{t('fileManager.unzip', 'Unzip')}</span>
</button>
)}
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
onDeleteFile(file.id);
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
setShowActions(false);
}}
>
<CloseIcon fontSize="small" />
<span>{t('close', 'Close')}</span>
<DeleteOutlineIcon fontSize="small" />
<span>{t('delete', 'Delete')}</span>
</button>
</div>
)}
@@ -436,6 +377,13 @@ const FileEditorThumbnail = ({
)}
</div>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
@@ -5,12 +5,10 @@ import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileId, StirlingFileStub } from '../../types/fileContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { zipFileService } from '../../services/zipFileService';
import ToolChain from '../shared/ToolChain';
interface FileListItemProps {
@@ -40,10 +38,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
@@ -197,22 +192,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
</>
)}
{/* Unzip option for ZIP files */}
{isZipFile && !isHistoryFile && (
<>
<Menu.Item
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onUnzipFile(file);
}}
>
{t('fileManager.unzip', 'Unzip')}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
@@ -5,7 +5,6 @@ import UploadIcon from '@mui/icons-material/Upload';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -14,20 +13,8 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const handleGoogleDriveClick = async () => {
try {
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
onGoogleDriveSelect(files);
}
} catch (error) {
console.error('Failed to pick files from Google Drive:', error);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
@@ -80,24 +67,15 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
variant={buttonProps.variant('drive')}
leftSection={<CloudIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleGoogleDriveClick}
onClick={() => onSourceChange('drive')}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
disabled={!isGoogleDriveEnabled}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
}
}
}}
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
disabled
color={activeSource === 'drive' ? 'gray' : undefined}
styles={buttonProps.getStyles('drive')}
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
@@ -55,4 +55,4 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 's
);
};
export default HotkeyDisplay;
export default HotkeyDisplay;
+4 -7
View File
@@ -146,12 +146,10 @@ export default function Workbench() {
}
>
{/* Top Controls */}
{activeFiles.length > 0 && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
/>
)}
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
/>
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
@@ -161,7 +159,6 @@ export default function Workbench() {
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
}}
>
{renderMainContent()}
@@ -5,8 +5,6 @@ import { useNavigationGuard } from "../../contexts/NavigationContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
// Thumbnail generation is now handled by individual PageThumbnail components
import './PageEditor.module.css';
import PageThumbnail from './PageThumbnail';
@@ -526,38 +524,66 @@ const PageEditor = ({
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 2: Check if we have multiple documents (splits) or single document
if (Array.isArray(processedDocuments)) {
// Multiple documents (splits) - export as ZIP
const blobs: Blob[] = [];
const filenames: string[] = [];
// Step 3: Download
if (files.length > 1) {
// Multiple files - create ZIP
const sourceFiles = getSourceFiles();
const baseExportFilename = getExportFilename();
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
for (let i = 0; i < processedDocuments.length; i++) {
const doc = processedDocuments[i];
const partFilename = `${baseName}_part_${i + 1}.pdf`;
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
blobs.push(result.blob);
filenames.push(result.filename);
}
// Create ZIP file
const JSZip = await import('jszip');
const zip = new JSZip.default();
files.forEach((file) => {
zip.file(file.name, file);
blobs.forEach((blob, index) => {
zip.file(filenames[index], blob);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const exportFilename = getExportFilename();
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
} else {
// Single file - download directly
const file = files[0];
pdfExportService.downloadFile(file, file.name);
// Single document - regular export
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
processedDocuments,
sourceFiles,
[],
{ selectedOnly: false, filename: exportFilename }
)
: await pdfExportService.exportPDF(
processedDocuments,
[],
{ selectedOnly: false, filename: exportFilename }
);
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
@@ -566,39 +592,21 @@ const PageEditor = ({
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(async () => {
const applyChanges = useCallback(() => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
);
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
setEditedDocument(documentToSet);
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Apply changes failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
}, [displayDocument, mergedPdfDocument, splitPositions]);
const closePdf = useCallback(() => {
@@ -662,7 +670,7 @@ const PageEditor = ({
const displayedPages = displayDocument?.pages || [];
return (
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
@@ -785,7 +793,7 @@ const PageEditor = ({
<NavigationWarningModal
onApplyAndContinue={async () => {
await applyChanges();
applyChanges();
}}
onExportAndContinue={async () => {
await onExportAll();
@@ -375,7 +375,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
data-original-rotation={page.rotation}
style={{
width: '100%',
height: '100%',
@@ -17,34 +17,32 @@ export class RotatePageCommand extends DOMCommand {
}
execute(): void {
// Only update DOM for immediate visual feedback
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property to match the animated CSS
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let newRotation = currentRotation + this.degrees;
newRotation = ((newRotation % 360) + 360) % 360;
const newRotation = currentRotation + this.degrees;
img.style.transform = `rotate(${newRotation}deg)`;
}
}
}
undo(): void {
// Only update DOM
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let previousRotation = currentRotation - this.degrees;
previousRotation = ((previousRotation % 360) + 360) % 360;
const previousRotation = currentRotation - this.degrees;
img.style.transform = `rotate(${previousRotation}deg)`;
}
}
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
const FileUploadButton = ({
file,
onChange,
accept,
accept = "*/*",
disabled = false,
placeholder,
variant = "outline",
@@ -41,6 +41,7 @@ const LandingPage = () => {
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["*/*"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95%]"
style={{
@@ -177,6 +178,7 @@ const LandingPage = () => {
ref={fileInputRef}
type="file"
multiple
accept="*/*"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
@@ -1,19 +1,25 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useNavigationGuard } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
}
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const NavigationWarningModal = ({
onApplyAndContinue: _onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const {
showNavigationWarning,
hasUnsavedChanges,
cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();
@@ -24,22 +30,14 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
confirmNavigation();
};
const handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const _handleExportAndContinue = async () => {
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const BUTTON_WIDTH = "10rem";
if (!hasUnsavedChanges) {
return null;
@@ -51,53 +49,54 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
onClose={handleKeepWorking}
title={t("unsavedChangesTitle", "Unsaved Changes")}
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
size="lg"
closeOnClickOutside={false}
closeOnEscape={false}
>
<Stack>
<Stack ta="center" p="md">
<Text size="md" fw="300">
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
<Stack gap="md">
<Text>
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
</Text>
<Text size="lg" fw="500" >
{t("areYouSure", "Are you sure you want to leave?")}
</Text>
</Stack>
{/* Desktop layout: 2 groups side by side */}
<Group justify="space-between" gap="xl" visibleFrom="md">
<Group justify="space-between" gap="sm">
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
{t("discardChanges", "Discard Changes")}
</Button>
<Group gap="sm">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={handleKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
</Group>
<Group gap="sm">
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
{/* TODO:: Add this back in when it works */}
{/* {_onApplyAndContinue && (
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
>
{t("applyAndContinue", "Apply & Continue")}
</Button>
)} */}
{onExportAndContinue && (
<Button
onClick={handleExportAndContinue}
>
{t("exportAndContinue", "Export & Continue")}
</Button>
)}
</Group>
</Group>
{/* Mobile layout: centered stack of 4 buttons */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
</Stack>
</Stack>
</Modal>
);
@@ -1,7 +1,6 @@
import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
export interface ConfigNavItem {
key: NavKey;
@@ -44,12 +43,6 @@ export const createConfigNavSections = (
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
@@ -1,89 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
</Stack>
);
};
export default GeneralSection;
@@ -4,9 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
import { useHotkeys } from '../../../../contexts/HotkeyContext';
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys';
import { ToolId } from 'src/types/toolId';
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys';
const rowStyle: React.CSSProperties = {
display: 'flex',
@@ -26,10 +24,10 @@ const HotkeysSection: React.FC = () => {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [editingTool, setEditingTool] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
const tools = useMemo(() => Object.entries(toolRegistry), [toolRegistry]);
useEffect(() => {
if (!editingTool) {
@@ -66,7 +64,7 @@ const HotkeysSection: React.FC = () => {
return;
}
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
const conflictEntry = Object.entries(hotkeys).find(([toolId, existing]) => (
toolId !== editingTool && bindingEquals(existing, binding)
));
@@ -87,7 +85,7 @@ const HotkeysSection: React.FC = () => {
};
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
const handleStartCapture = (toolId: ToolId) => {
const handleStartCapture = (toolId: string) => {
setEditingTool(toolId);
setError(null);
};
@@ -170,4 +168,4 @@ const HotkeysSection: React.FC = () => {
);
};
export default HotkeysSection;
export default HotkeysSection;
@@ -51,6 +51,7 @@ const Overview: React.FC = () => {
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
@@ -10,7 +10,6 @@ import { useFileState, useFileContext } from '../../../contexts/FileContext';
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { createProcessedFile } from '../../../contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
import { useNavigationState } from '../../../contexts/NavigationContext';
interface ViewerAnnotationControlsProps {
currentView: string;
@@ -26,17 +25,13 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef, isPlacementMode } = useSignature();
const { signatureApiRef } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
@@ -44,11 +39,6 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
}
return (
<>
{/* Annotation Visibility Toggle */}
@@ -60,7 +50,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -1,44 +0,0 @@
import React from 'react';
import { Slider, Text, Group, NumberInput } from '@mantine/core';
interface Props {
label: string;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
}
export default function SliderWithInput({
label,
value,
onChange,
disabled,
min = 0,
max = 200,
step = 1,
}: Props) {
return (
<div>
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text>
<Group gap="sm" align="center">
<div style={{ flex: 1 }}>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
</div>
<NumberInput
value={value}
onChange={(v) => onChange(Number(v) || 0)}
min={min}
max={max}
step={step}
disabled={disabled}
style={{ width: 90 }}
/>
</Group>
</div>
);
}
@@ -7,10 +7,9 @@ import { useToolSections } from '../../hooks/useToolSections';
import SubcategoryHeader from './shared/SubcategoryHeader';
import NoToolsFound from './shared/NoToolsFound';
import "./toolPicker/ToolPicker.css";
import { ToolId } from 'src/types/toolId';
interface SearchResultsProps {
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
onSelect: (id: string) => void;
searchQuery?: string;
}
@@ -41,13 +40,13 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect,
{group.tools.map(({ id, tool }) => {
const matchedText = matchedTextMap.get(id);
// Check if the match was from synonyms and show the actual synonym that matched
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
);
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
) : undefined;
return (
<ToolButton
key={id}
+1 -2
View File
@@ -6,12 +6,11 @@ import "./toolPicker/ToolPicker.css";
import { useToolSections } from "../../hooks/useToolSections";
import NoToolsFound from "./shared/NoToolsFound";
import { renderToolButtons } from "./shared/renderToolButtons";
import { ToolId } from "src/types/toolId";
interface ToolPickerProps {
selectedToolKey: string | null;
onSelect: (id: string) => void;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
isSearching?: boolean;
}
@@ -2,10 +2,9 @@ import { Suspense } from "react";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { BaseToolProps } from "../../types/tool";
import ToolLoadingFallback from "./ToolLoadingFallback";
import { ToolId } from "src/types/toolId";
interface ToolRendererProps extends BaseToolProps {
selectedToolKey: ToolId;
selectedToolKey: string;
}
@@ -1,25 +0,0 @@
import React from 'react';
import { Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
interface Props {
parameters: AdjustContrastParameters;
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
disabled?: boolean;
}
export default function AdjustContrastBasicSettings({ parameters, onParameterChange, disabled }: Props) {
const { t } = useTranslation();
return (
<Stack gap="md">
<SliderWithInput label={t('adjustContrast.contrast', 'Contrast')} value={parameters.contrast} onChange={(v) => onParameterChange('contrast', v as any)} disabled={disabled} />
<SliderWithInput label={t('adjustContrast.brightness', 'Brightness')} value={parameters.brightness} onChange={(v) => onParameterChange('brightness', v as any)} disabled={disabled} />
<SliderWithInput label={t('adjustContrast.saturation', 'Saturation')} value={parameters.saturation} onChange={(v) => onParameterChange('saturation', v as any)} disabled={disabled} />
</Stack>
);
}
@@ -1,25 +0,0 @@
import React from 'react';
import { Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
interface Props {
parameters: AdjustContrastParameters;
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
disabled?: boolean;
}
export default function AdjustContrastColorSettings({ parameters, onParameterChange, disabled }: Props) {
const { t } = useTranslation();
return (
<Stack gap="md">
<SliderWithInput label={t('adjustContrast.red', 'Red')} value={parameters.red} onChange={(v) => onParameterChange('red', v as any)} disabled={disabled} />
<SliderWithInput label={t('adjustContrast.green', 'Green')} value={parameters.green} onChange={(v) => onParameterChange('green', v as any)} disabled={disabled} />
<SliderWithInput label={t('adjustContrast.blue', 'Blue')} value={parameters.blue} onChange={(v) => onParameterChange('blue', v as any)} disabled={disabled} />
</Stack>
);
}
@@ -1,93 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
import ObscuredOverlay from '../../shared/ObscuredOverlay';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { applyAdjustmentsToCanvas } from './utils';
interface Props {
file: File | null;
parameters: AdjustContrastParameters;
}
export default function AdjustContrastPreview({ file, parameters }: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [thumb, setThumb] = useState<string | null>(null);
const { requestThumbnail } = useThumbnailGeneration();
useEffect(() => {
let active = true;
const load = async () => {
if (!file || file.type !== 'application/pdf') { setThumb(null); return; }
const id = `${file.name}:${file.size}:${file.lastModified}:page:1`;
const tUrl = await requestThumbnail(id, file, 1);
if (active) setThumb(tUrl || null);
};
load();
return () => { active = false; };
}, [file, requestThumbnail]);
useEffect(() => {
const revoked: string | null = null;
const render = async () => {
if (!thumb || !canvasRef.current) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = thumb;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject();
});
// Draw thumbnail to a source canvas
const src = document.createElement('canvas');
src.width = img.naturalWidth;
src.height = img.naturalHeight;
const sctx = src.getContext('2d');
if (!sctx) return;
sctx.drawImage(img, 0, 0);
// Apply accurate pixel adjustments
const adjusted = applyAdjustmentsToCanvas(src, parameters);
// Draw adjusted onto display canvas
const display = canvasRef.current;
display.width = adjusted.width;
display.height = adjusted.height;
const dctx = display.getContext('2d');
if (!dctx) return;
dctx.clearRect(0, 0, display.width, display.height);
dctx.drawImage(adjusted, 0, 0);
};
render();
return () => {
if (revoked) URL.revokeObjectURL(revoked);
};
}, [thumb, parameters]);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
<div style={{ fontSize: 12, color: 'var(--text-color-muted)' }}>{t('common.preview', 'Preview')}</div>
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
</div>
<ObscuredOverlay
obscured={!thumb}
overlayMessage={<Text size="sm" c="white" fw={600}>{t('adjustContrast.noPreview', 'Select a PDF to preview')}</Text>}
borderRadius={6}
>
<div ref={containerRef} style={{ aspectRatio: '8.5/11', width: '100%', border: '1px solid var(--border-color)', borderRadius: 8, overflow: 'hidden' }}>
{thumb && (
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
)}
</div>
</ObscuredOverlay>
</div>
);
}
@@ -1,31 +0,0 @@
import React from 'react';
import { Stack } from '@mantine/core';
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
import AdjustContrastBasicSettings from './AdjustContrastBasicSettings';
import AdjustContrastColorSettings from './AdjustContrastColorSettings';
interface Props {
parameters: AdjustContrastParameters;
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
disabled?: boolean;
}
// Single-step settings used by Automate to configure Adjust Contrast in one panel
export default function AdjustContrastSingleStepSettings({ parameters, onParameterChange, disabled }: Props) {
return (
<Stack gap="lg">
<AdjustContrastBasicSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
<AdjustContrastColorSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
);
}
@@ -1,79 +0,0 @@
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
export function applyAdjustmentsToCanvas(src: HTMLCanvasElement, params: AdjustContrastParameters): HTMLCanvasElement {
const out = document.createElement('canvas');
out.width = src.width;
out.height = src.height;
const ctx = out.getContext('2d');
if (!ctx) return src;
ctx.drawImage(src, 0, 0);
const imageData = ctx.getImageData(0, 0, out.width, out.height);
const data = imageData.data;
const contrast = params.contrast / 100; // 0..2
const brightness = params.brightness / 100; // 0..2
const saturation = params.saturation / 100; // 0..2
const redMul = params.red / 100; // 0..2
const greenMul = params.green / 100; // 0..2
const blueMul = params.blue / 100; // 0..2
const clamp = (v: number) => Math.min(255, Math.max(0, v));
for (let i = 0; i < data.length; i += 4) {
let r = data[i] * redMul;
let g = data[i + 1] * greenMul;
let b = data[i + 2] * blueMul;
// Contrast (centered at 128)
r = clamp((r - 128) * contrast + 128);
g = clamp((g - 128) * contrast + 128);
b = clamp((b - 128) * contrast + 128);
// Brightness
r = clamp(r * brightness);
g = clamp(g * brightness);
b = clamp(b * brightness);
// Saturation via HSL
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
case gn: h = (bn - rn) / d + 2; break;
default: h = (rn - gn) / d + 4; break;
}
h /= 6;
}
s = Math.min(1, Math.max(0, s * saturation));
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
let r2: number, g2: number, b2: number;
if (s === 0) { r2 = g2 = b2 = l; }
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r2 = hue2rgb(p, q, h + 1/3);
g2 = hue2rgb(p, q, h);
b2 = hue2rgb(p, q, h - 1/3);
}
data[i] = clamp(Math.round(r2 * 255));
data[i + 1] = clamp(Math.round(g2 * 255));
data[i + 2] = clamp(Math.round(b2 * 255));
}
ctx.putImageData(imageData, 0, 0);
return out;
}
@@ -6,7 +6,6 @@ import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
import ToolButton from '../toolPicker/ToolButton';
import { ToolId } from 'src/types/toolId';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
@@ -31,7 +30,7 @@ export default function ToolSelector({
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
const baseFilteredTools = useMemo(() => {
return (Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]).filter(([key, tool]) =>
return Object.entries(toolRegistry).filter(([key, tool]) =>
!excludeTools.includes(key) && getToolSupportsAutomate(tool)
);
}, [toolRegistry, excludeTools]);
@@ -67,7 +66,7 @@ export default function ToolSelector({
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(transformedFilteredTools as any /* FIX ME */);
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
@@ -157,7 +156,7 @@ export default function ToolSelector({
// Show selected tool in AutomationEntry style when tool is selected and dropdown closed
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
borderRadius: "var(--mantine-radius-lg)" }}>
<ToolButton id={'tool' as any /* FIX ME */} tool={toolRegistry[selectedValue]} isSelected={false}
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
</div>
) : (
@@ -54,8 +54,6 @@ export interface ToolFlowConfig {
title?: TitleConfig;
files: FilesStepConfig;
steps: MiddleStepConfig[];
// Optional preview content rendered between steps and the execute button
preview?: React.ReactNode;
executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig;
forceStepNumbers?: boolean;
@@ -92,10 +90,6 @@ export function createToolFlow(config: ToolFlowConfig) {
}, stepConfig.content)
)}
{/* Preview (outside steps, above execute button).
Hide when review is visible or when no files are selected. */}
{!config.review.isVisible && (config.files.selectedFiles?.length ?? 0) > 0 && config.preview}
{/* Execute Button */}
{config.executeButton && config.executeButton.isVisible !== false && (
<OperationButton
@@ -5,14 +5,13 @@ import SubcategoryHeader from './SubcategoryHeader';
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
import { TFunction } from 'i18next';
import { SubcategoryGroup } from '../../../hooks/useToolSections';
import { ToolId } from 'src/types/toolId';
// Helper function to render tool buttons for a subcategory
export const renderToolButtons = (
t: TFunction,
subcategory: SubcategoryGroup,
selectedToolKey: string | null,
onSelect: (id: ToolId) => void,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true,
disableNavigation: boolean = false,
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
@@ -33,7 +32,7 @@ export const renderToolButtons = (
<div>
{subcategory.tools.map(({ id, tool }) => {
const matchedSynonym = matchedTextMap.get(id);
return (
<ToolButton
key={id}
@@ -1,9 +1,8 @@
import { useState, useEffect } from 'react';
import { useTranslation } from "react-i18next";
import { Stack, Button, Text, Alert, Tabs, SegmentedControl } from '@mantine/core';
import { Stack, Button, Text, Alert, Tabs } from '@mantine/core';
import { SignParameters } from "../../../hooks/tools/sign/useSignParameters";
import { SuggestedToolsSection } from "../shared/SuggestedToolsSection";
import { useSignature } from "../../../contexts/SignatureContext";
// Import the new reusable components
import { DrawingCanvas } from "../../annotation/shared/DrawingCanvas";
@@ -36,14 +35,12 @@ const SignSettings = ({
onSave
}: SignSettingsProps) => {
const { t } = useTranslation();
const { isPlacementMode } = useSignature();
// State for drawing
const [selectedColor, setSelectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move');
// State for different signature types
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
@@ -99,29 +96,20 @@ const SignSettings = ({
}
}, [parameters.signatureType]);
// Handle text signature activation (including fontSize and fontFamily changes)
// Handle text signature activation
useEffect(() => {
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
if (onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
}
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
if (onDeactivateSignature) {
setInteractionMode('move');
onDeactivateSignature();
}
}
}, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]);
// Reset to move mode when placement mode is deactivated
useEffect(() => {
if (!isPlacementMode && interactionMode === 'place') {
setInteractionMode('move');
}
}, [isPlacementMode, interactionMode]);
}, [parameters.signatureType, parameters.signerName, onActivateSignaturePlacement, onDeactivateSignature]);
// Handle signature data updates
useEffect(() => {
@@ -142,23 +130,12 @@ const SignSettings = ({
// Handle image signature activation - activate when image data syncs with parameters
useEffect(() => {
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
}
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
// Handle canvas signature activation - activate when canvas data syncs with parameters
useEffect(() => {
if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
}
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData]);
// Draw settings are no longer needed since draw mode is removed
return (
@@ -193,7 +170,7 @@ const SignSettings = ({
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
disabled={disabled}
showPlaceButton={false}
placeButtonText={t('sign.updateAndPlace', 'Update and Place')}
placeButtonText="Update and Place"
/>
{/* Signature Creation based on type */}
@@ -206,11 +183,6 @@ const SignSettings = ({
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
onDrawingComplete={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
}}
disabled={disabled}
additionalButtons={
<Button
@@ -223,7 +195,7 @@ const SignSettings = ({
variant="filled"
disabled={disabled || !canvasSignatureData}
>
{t('sign.updateAndPlace', 'Update and Place')}
Update and Place
</Button>
}
/>
@@ -244,43 +216,17 @@ const SignSettings = ({
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
)}
{/* Interaction Mode Toggle */}
{(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && (
<SegmentedControl
value={interactionMode}
onChange={(value) => {
setInteractionMode(value as 'move' | 'place');
if (value === 'place') {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
} else {
if (onDeactivateSignature) {
onDeactivateSignature();
}
}
}}
data={[
{ label: t('sign.mode.move', 'Move Signature'), value: 'move' },
{ label: t('sign.mode.place', 'Place Signature'), value: 'place' }
]}
fullWidth
/>
)}
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
{parameters.signatureType === 'canvas' && t('sign.instructions.canvas', 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.')}
{parameters.signatureType === 'image' && t('sign.instructions.image', 'After uploading your signature image above, click anywhere on the PDF to place it.')}
{parameters.signatureType === 'text' && t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.')}
{parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'}
{parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'}
{parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'}
</Text>
</Alert>
@@ -9,13 +9,12 @@ import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
import { useHotkeys } from "../../../contexts/HotkeyContext";
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
import { ToolId } from "src/types/toolId";
interface ToolButtonProps {
id: ToolId;
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onSelect: (id: ToolId) => void;
onSelect: (id: string) => void;
rounded?: boolean;
disableNavigation?: boolean;
matchedSynonym?: string;
@@ -29,7 +28,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation();
const handleClick = (id: ToolId) => {
const handleClick = (id: string) => {
if (isUnavailable) return;
if (tool.link) {
// Open external link in new tab
@@ -48,16 +47,12 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
{binding ? (
<>
{binding && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>{t('settings.hotkeys.shortcut', 'Shortcut')}</span>
<HotkeyDisplay binding={binding} />
</>
) : (
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500, fontStyle: 'italic' }}>{t('settings.hotkeys.noShortcut', 'No shortcut set')}</span>
</div>
)}
</div>
</div>
);
@@ -107,7 +102,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
label: { overflow: 'visible' }
}}
@@ -128,7 +123,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
label: { overflow: 'visible' }
}}
@@ -1,19 +1,16 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React from 'react';
import { Box, Center, Text, ActionIcon } from '@mantine/core';
import { useMantineTheme, useMantineColorScheme } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import { useFileState, useFileActions } from "../../contexts/FileContext";
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { useViewer } from "../../contexts/ViewerContext";
import { LocalEmbedPDF } from './LocalEmbedPDF';
import { PdfViewerToolbar } from './PdfViewerToolbar';
import { ThumbnailSidebar } from './ThumbnailSidebar';
import { useNavigationGuard, useNavigationState } from '../../contexts/NavigationContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useSignature } from '../../contexts/SignatureContext';
import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers';
import NavigationWarningModal from '../shared/NavigationWarningModal';
import { isStirlingFile } from '../../types/fileContext';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@@ -32,33 +29,11 @@ const EmbedPdfViewerContent = ({
const { colorScheme: _colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer();
const scrollState = getScrollState();
const zoomState = getZoomState();
const spreadState = getSpreadState();
const rotationState = getRotationState();
// Track initial rotation to detect changes
const initialRotationRef = useRef<number | null>(null);
useEffect(() => {
if (initialRotationRef.current === null && rotationState.rotation !== undefined) {
initialRotationRef.current = rotationState.rotation;
}
}, [rotationState.rotation]);
// Get signature context
const { signatureApiRef, historyApiRef } = useSignature();
// Get current file from FileContext
const { selectors } = useFileState();
const { actions } = useFileActions();
const activeFiles = selectors.getFiles();
const activeFileIds = activeFiles.map(f => f.fileId);
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
// Check if we're in signature mode OR viewer annotation mode
const { selectedTool } = useNavigationState();
@@ -67,6 +42,13 @@ const EmbedPdfViewerContent = ({
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
// Get signature context
const { signatureApiRef, historyApiRef } = useSignature();
// Get current file from FileContext
const { selectors } = useFileState();
const activeFiles = selectors.getFiles();
// Determine which file to display
const currentFile = React.useMemo(() => {
if (previewFile) {
@@ -152,65 +134,6 @@ const EmbedPdfViewerContent = ({
};
}, [isViewerHovered]);
// Register checker for unsaved changes (annotations only for now)
useEffect(() => {
if (previewFile) {
return;
}
const checkForChanges = () => {
// Check for annotation changes via history
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges
});
return hasAnnotationChanges;
};
console.log('[Viewer] Registering unsaved changes checker');
registerUnsavedChangesChecker(checkForChanges);
return () => {
console.log('[Viewer] Unregistering unsaved changes checker');
unregisterUnsavedChangesChecker();
};
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
// Apply changes - save annotations to new file version
const applyChanges = useCallback(async () => {
if (!currentFile || activeFileIds.length === 0) return;
try {
console.log('[Viewer] Applying changes - exporting PDF with annotations');
// Step 1: Export PDF with annotations using EmbedPDF
const arrayBuffer = await exportActions.saveAsCopy();
if (!arrayBuffer) {
throw new Error('Failed to export PDF');
}
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
// Step 2: Convert ArrayBuffer to File
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const filename = currentFile.name || 'document.pdf';
const file = new File([blob], filename, { type: 'application/pdf' });
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
setHasUnsavedChanges(false);
} catch (error) {
console.error('Apply changes failed:', error);
}
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
return (
<Box
@@ -264,7 +187,6 @@ const EmbedPdfViewerContent = ({
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
url={effectiveFile.url}
enableAnnotations={shouldEnableAnnotations}
@@ -318,15 +240,6 @@ const EmbedPdfViewerContent = ({
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
/>
{/* Navigation Warning Modal */}
{!previewFile && (
<NavigationWarningModal
onApplyAndContinue={async () => {
await applyChanges();
}}
/>
)}
</Box>
);
};
@@ -274,7 +274,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
<GlobalPointerProvider>
<Viewport
style={{
backgroundColor: 'var(--bg-background)',
backgroundColor: 'var(--bg-surface)',
height: '100%',
width: '100%',
maxHeight: '100%',
@@ -299,8 +299,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
msUserSelect: 'none'
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
@@ -314,7 +313,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer
@@ -1,28 +1,32 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
import { useSignature } from '../../contexts/SignatureContext';
import { useViewer } from '../../contexts/ViewerContext';
export interface SignatureAPI {
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
addTextSignature: (text: string, x: number, y: number, pageIndex: number) => void;
activateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
deleteAnnotation: (annotationId: string, pageIndex: number) => void;
updateDrawSettings: (color: string, size: number) => void;
deactivateTools: () => void;
applySignatureFromParameters: (params: SignParameters) => void;
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
}
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
const { isAnnotationMode } = useViewer();
// Enable keyboard deletion of selected annotations
// Enable keyboard deletion of selected annotations - when in signature placement mode or viewer annotation mode
useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode
if (!annotationApi || (isPlacementMode === undefined)) return;
if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
@@ -63,7 +67,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [annotationApi, storeImageData, isPlacementMode]);
}, [annotationApi, storeImageData, isPlacementMode, isAnnotationMode]);
useImperativeHandle(ref, () => ({
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
@@ -96,6 +100,34 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
});
},
addTextSignature: (text: string, x: number, y: number, pageIndex: number) => {
if (!annotationApi) return;
// Create text annotation for signature
annotationApi.createAnnotation(pageIndex, {
type: PdfAnnotationSubtype.FREETEXT,
rect: {
origin: { x, y },
size: { width: 200, height: 50 }
},
contents: text,
author: 'Digital Signature',
fontSize: 16,
fontColor: '#000000',
fontFamily: PdfStandardFont.Helvetica,
textAlign: PdfTextAlignment.Left,
verticalAlign: PdfVerticalAlignment.Top,
opacity: 1,
pageIndex: pageIndex,
id: uuidV4(),
created: new Date(),
customData: {
signatureText: text,
signatureType: 'text'
}
});
},
activateDrawMode: () => {
if (!annotationApi) return;
@@ -120,31 +152,45 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
try {
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
// Skip native text tools - always use stamp for consistent sizing
const activatedTool = null;
// Try different tool names for text annotations
const textToolNames = ['freetext', 'text', 'textbox', 'annotation-text'];
let activatedTool = null;
for (const toolName of textToolNames) {
annotationApi.setActiveTool(toolName);
const tool = annotationApi.getActiveTool();
if (tool && tool.id === toolName) {
activatedTool = tool;
annotationApi.setToolDefaults(toolName, {
contents: signatureConfig.signerName,
fontSize: signatureConfig.fontSize || 16,
fontFamily: signatureConfig.fontFamily === 'Times-Roman' ? PdfStandardFont.Times_Roman :
signatureConfig.fontFamily === 'Courier' ? PdfStandardFont.Courier :
PdfStandardFont.Helvetica,
fontColor: '#000000',
});
break;
}
}
if (!activatedTool) {
// Create text image as stamp with actual pixel size matching desired display size
// Fallback: create a simple text image as stamp
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
const baseFontSize = signatureConfig.fontSize || 16;
const fontSize = signatureConfig.fontSize || 16;
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
const textColor = signatureConfig.textColor || '#000000';
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
canvas.height = baseFontSize + 20;
ctx.fillStyle = textColor;
ctx.font = `${baseFontSize}px ${fontFamily}`;
canvas.width = Math.max(200, signatureConfig.signerName.length * fontSize * 0.6);
canvas.height = fontSize + 20;
ctx.fillStyle = '#000000';
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
const dataURL = canvas.toDataURL();
// Deactivate and reactivate to force refresh
annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
@@ -159,7 +205,6 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
// Use stamp tool for image/canvas signatures
annotationApi.setActiveTool('stamp');
const activeTool = annotationApi.getActiveTool();
if (activeTool && activeTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc: signatureConfig.signatureData,
@@ -222,6 +267,84 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
annotationApi.setActiveTool(null);
},
applySignatureFromParameters: (params: SignParameters) => {
if (!annotationApi || !params.signaturePosition) return;
const { x, y, width, height, page } = params.signaturePosition;
switch (params.signatureType) {
case 'image':
if (params.signatureData) {
const annotationId = uuidV4();
// Store image data in our persistent store
storeImageData(annotationId, params.signatureData);
annotationApi.createAnnotation(page, {
type: PdfAnnotationSubtype.STAMP,
rect: {
origin: { x, y },
size: { width, height }
},
author: 'Digital Signature',
subject: `Digital Signature - ${params.reason || 'Document signing'}`,
pageIndex: page,
id: annotationId,
created: new Date(),
// Store image data in multiple places to ensure history captures it
imageSrc: params.signatureData,
contents: params.signatureData, // Some annotation systems use contents
data: params.signatureData, // Try data field
imageData: params.signatureData, // Try imageData field
appearance: params.signatureData // Try appearance field
});
// Switch to select mode after placing signature so it can be easily deleted
setTimeout(() => {
annotationApi.setActiveTool('select');
}, 100);
}
break;
case 'text':
if (params.signerName) {
annotationApi.createAnnotation(page, {
type: PdfAnnotationSubtype.FREETEXT,
rect: {
origin: { x, y },
size: { width, height }
},
contents: params.signerName,
author: 'Digital Signature',
fontSize: 16,
fontColor: '#000000',
fontFamily: PdfStandardFont.Helvetica,
textAlign: PdfTextAlignment.Left,
verticalAlign: PdfVerticalAlignment.Top,
opacity: 1,
pageIndex: page,
id: uuidV4(),
created: new Date(),
customData: {
signatureText: params.signerName,
signatureType: 'text'
}
});
// Switch to select mode after placing signature so it can be easily deleted
setTimeout(() => {
annotationApi.setActiveTool('select');
}, 100);
}
break;
case 'draw':
// For draw mode, we activate the tool and let user draw
annotationApi.setActiveTool('ink');
break;
}
},
getPageAnnotations: async (pageIndex: number): Promise<any[]> => {
if (!annotationApi || !annotationApi.getPageAnnotations) {
console.warn('getPageAnnotations not available');
@@ -12,42 +12,30 @@ export function ZoomAPIBridge() {
// Set initial zoom once when plugin is ready
useEffect(() => {
if (!zoom || hasSetInitialZoom.current) {
return;
if (zoom && !hasSetInitialZoom.current) {
hasSetInitialZoom.current = true;
setTimeout(() => {
try {
zoom.requestZoom(1.4);
} catch (error) {
console.log('Zoom initialization delayed, viewport not ready:', error);
// Retry after a longer delay
setTimeout(() => {
try {
zoom.requestZoom(1.4);
} catch (retryError) {
console.log('Zoom initialization failed:', retryError);
}
}, 200);
}
}, 50);
}
let retryTimer: ReturnType<typeof setTimeout> | undefined;
const attemptInitialZoom = () => {
try {
zoom.requestZoom(1.4);
hasSetInitialZoom.current = true;
} catch (error) {
console.log('Zoom initialization delayed, viewport not ready:', error);
retryTimer = setTimeout(() => {
try {
zoom.requestZoom(1.4);
hasSetInitialZoom.current = true;
} catch (retryError) {
console.log('Zoom initialization failed:', retryError);
}
}, 200);
}
};
const timer = setTimeout(attemptInitialZoom, 50);
return () => {
clearTimeout(timer);
if (retryTimer) {
clearTimeout(retryTimer);
}
};
}, [zoom, zoomState]);
}, [zoom]);
useEffect(() => {
if (zoom && zoomState) {
// Update local state
const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4;
const currentZoomLevel = zoomState.currentZoomLevel || 1.4;
const newState = {
currentZoom: currentZoomLevel,
zoomPercent: Math.round(currentZoomLevel * 100),
@@ -1,6 +1,5 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { fileStorage } from '../services/fileStorage';
import { zipFileService } from '../services/zipFileService';
import { StirlingFileStub } from '../types/fileContext';
import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file';
@@ -37,9 +36,7 @@ interface FileManagerContextValue {
onDownloadSingle: (file: StirlingFileStub) => void;
onToggleExpansion: (fileId: FileId) => void;
onAddToRecents: (file: StirlingFileStub) => void;
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
onNewFilesSelect: (files: File[]) => void;
onGoogleDriveSelect: (files: File[]) => void;
// External props
recentFiles: StirlingFileStub[];
@@ -547,43 +544,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [refreshRecentFiles]);
const handleGoogleDriveSelect = useCallback(async (files: File[]) => {
if (files.length > 0) {
try {
// Process Google Drive files same as local files
onNewFilesSelect(files);
await refreshRecentFiles();
onClose();
} catch (error) {
console.error('Failed to process Google Drive files:', error);
}
}
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
const handleUnzipFile = useCallback(async (file: StirlingFileStub) => {
try {
// Load the full file from storage
const stirlingFile = await fileStorage.getStirlingFile(file.id);
if (!stirlingFile) {
return;
}
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(stirlingFile, file);
if (result.success) {
// Refresh file manager to show new files
await refreshRecentFiles();
}
if (result.errors.length > 0) {
console.error('Errors during unzip:', result.errors);
}
} catch (error) {
console.error('Failed to unzip file:', error);
}
}, [refreshRecentFiles]);
// Cleanup blob URLs when component unmounts
useEffect(() => {
return () => {
@@ -635,9 +595,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onDownloadSingle: handleDownloadSingle,
onToggleExpansion: handleToggleExpansion,
onAddToRecents: handleAddToRecents,
onUnzipFile: handleUnzipFile,
onNewFilesSelect,
onGoogleDriveSelect: handleGoogleDriveSelect,
// External props
recentFiles,
@@ -669,9 +627,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
handleDownloadSelected,
handleToggleExpansion,
handleAddToRecents,
handleUnzipFile,
onNewFilesSelect,
handleGoogleDriveSelect,
recentFiles,
isFileSupported,
modalHeight,
+55 -49
View File
@@ -2,17 +2,14 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '../utils/hotkeys';
import { useToolWorkflow } from './ToolWorkflowContext';
import { ToolId } from '../types/toolId';
import { ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy';
type Bindings = Partial<Record<ToolId, HotkeyBinding>>;
interface HotkeyContextValue {
hotkeys: Bindings;
defaults: Bindings;
hotkeys: Record<string, HotkeyBinding>;
defaults: Record<string, HotkeyBinding>;
isMac: boolean;
updateHotkey: (toolId: ToolId, binding: HotkeyBinding) => void;
resetHotkey: (toolId: ToolId) => void;
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: ToolId) => boolean;
updateHotkey: (toolId: string, binding: HotkeyBinding) => void;
resetHotkey: (toolId: string) => void;
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: string) => boolean;
pauseHotkeys: () => void;
resumeHotkeys: () => void;
areHotkeysPaused: boolean;
@@ -23,29 +20,46 @@ const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
const STORAGE_KEY = 'stirlingpdf.hotkeys';
const generateDefaultHotkeys = (toolEntries: [ToolId, ToolRegistryEntry][], macLike: boolean): Bindings => {
const defaults: Bindings = {};
const KEY_ORDER: string[] = [
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP',
'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL',
'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM',
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
];
// Get Quick Access tools (RECOMMENDED_TOOLS category) from registry
const quickAccessTools = toolEntries
.filter(([_, tool]) => tool.categoryId === ToolCategoryId.RECOMMENDED_TOOLS)
.map(([toolId, _]) => toolId);
const generateDefaultHotkeys = (toolIds: string[], macLike: boolean): Record<string, HotkeyBinding> => {
const defaults: Record<string, HotkeyBinding> = {};
let index = 0;
let useShift = false;
// Assign Cmd+Option+Number (Mac) or Ctrl+Alt+Number (Windows) to Quick Access tools
quickAccessTools.forEach((toolId, index) => {
if (index < 9) { // Limit to Digit1-9
const digitNumber = index + 1;
defaults[toolId] = {
code: `Digit${digitNumber}`,
alt: true,
shift: false,
meta: macLike,
ctrl: !macLike,
};
const nextBinding = (): HotkeyBinding => {
if (index >= KEY_ORDER.length) {
index = 0;
if (!useShift) {
useShift = true;
} else {
// If we somehow run out of combinations, wrap back around (unlikely given tool count)
useShift = false;
}
}
const code = KEY_ORDER[index];
index += 1;
return {
code,
alt: true,
shift: useShift,
meta: macLike,
ctrl: !macLike,
};
};
toolIds.forEach(toolId => {
defaults[toolId] = nextBinding();
});
// All other tools have no default (will be undefined in the record)
return defaults;
};
@@ -60,7 +74,7 @@ const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { toolRegistry, handleToolSelect } = useToolWorkflow();
const isMac = useMemo(() => isMacLike(), []);
const [customBindings, setCustomBindings] = useState<Bindings>(() => {
const [customBindings, setCustomBindings] = useState<Record<string, HotkeyBinding>>(() => {
if (typeof window === 'undefined') {
return {};
}
@@ -68,16 +82,16 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
});
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
const toolEntries = useMemo(() => Object.entries(toolRegistry), [toolRegistry]) as [ToolId, ToolRegistryEntry][];
const toolIds = useMemo(() => Object.keys(toolRegistry), [toolRegistry]);
const defaults = useMemo(() => generateDefaultHotkeys(toolEntries, isMac), [toolRegistry, isMac]);
const defaults = useMemo(() => generateDefaultHotkeys(toolIds, isMac), [toolIds, isMac]);
// Remove bindings for tools that are no longer present
useEffect(() => {
setCustomBindings(prev => {
const next: Bindings = {};
const next: Record<string, HotkeyBinding> = {};
let changed = false;
(Object.entries(prev) as [ToolId, HotkeyBinding][]).forEach(([toolId, binding]) => {
Object.entries(prev).forEach(([toolId, binding]) => {
if (toolRegistry[toolId]) {
next[toolId] = binding;
} else {
@@ -89,21 +103,13 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
}, [toolRegistry]);
const resolved = useMemo(() => {
const merged: Bindings = {};
toolEntries.forEach(([toolId, _]) => {
const merged: Record<string, HotkeyBinding> = {};
toolIds.forEach(toolId => {
const custom = customBindings[toolId];
const defaultBinding = defaults[toolId];
// Only add to resolved if there's a custom binding or a default binding
if (custom) {
merged[toolId] = normalizeBinding(custom);
} else if (defaultBinding) {
merged[toolId] = defaultBinding;
}
// If neither exists, don't add to merged (tool has no hotkey)
merged[toolId] = custom ? normalizeBinding(custom) : defaults[toolId];
});
return merged;
}, [customBindings, defaults, toolEntries]);
}, [customBindings, defaults, toolIds]);
useEffect(() => {
if (typeof window === 'undefined') {
@@ -112,7 +118,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
}, [customBindings]);
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: ToolId) => {
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: string) => {
const normalized = normalizeBinding(binding);
return Object.entries(resolved).every(([toolId, existing]) => {
if (toolId === excludeToolId) {
@@ -122,7 +128,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
});
}, [resolved]);
const updateHotkey = useCallback((toolId: ToolId, binding: HotkeyBinding) => {
const updateHotkey = useCallback((toolId: string, binding: HotkeyBinding) => {
setCustomBindings(prev => {
const normalized = normalizeBinding(binding);
const defaultsForTool = defaults[toolId];
@@ -136,7 +142,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
});
}, [defaults]);
const resetHotkey = useCallback((toolId: ToolId) => {
const resetHotkey = useCallback((toolId: string) => {
setCustomBindings(prev => {
if (!(toolId in prev)) {
return prev;
@@ -159,12 +165,12 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
if (event.repeat) return;
if (shouldIgnoreTarget(event.target)) return;
const entries = Object.entries(resolved) as [ToolId, HotkeyBinding][];
const entries = Object.entries(resolved) as Array<[string, HotkeyBinding]>;
for (const [toolId, binding] of entries) {
if (bindingMatchesEvent(binding, event)) {
event.preventDefault();
event.stopPropagation();
handleToolSelect(toolId);
handleToolSelect(toolId as ToolId);
break;
}
}
@@ -202,4 +208,4 @@ export const useHotkeys = (): HotkeyContextValue => {
throw new Error('useHotkeys must be used within a HotkeyProvider');
}
return context;
};
};
+5 -42
View File
@@ -74,8 +74,6 @@ export interface NavigationContextActions {
setSelectedTool: (toolId: ToolId | null) => void;
setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
registerUnsavedChangesChecker: (checker: () => boolean) => void;
unregisterUnsavedChangesChecker: () => void;
showNavigationWarning: (show: boolean) => void;
requestNavigation: (navigationFn: () => void) => void;
confirmNavigation: () => void;
@@ -108,29 +106,11 @@ export const NavigationProvider: React.FC<{
}> = ({ children }) => {
const [state, dispatch] = useReducer(navigationReducer, initialState);
const toolRegistry = useFlatToolRegistry();
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
const actions: NavigationContextActions = {
setWorkbench: useCallback((workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
console.log('[NavigationContext] setWorkbench:', {
from: state.workbench,
to: workbench,
hasChecker: !!unsavedChangesCheckerRef.current,
hasUnsavedChanges
});
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
const leavingWorkbenchWithChanges =
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
if (leavingWorkbenchWithChanges) {
// Update state to reflect unsaved changes so modal knows
if (!state.hasUnsavedChanges) {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } });
}
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
const performWorkbenchChange = () => {
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
};
@@ -146,15 +126,8 @@ export const NavigationProvider: React.FC<{
}, []),
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
const leavingWorkbenchWithChanges =
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
if (leavingWorkbenchWithChanges) {
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
const performWorkbenchChange = () => {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
};
@@ -169,14 +142,6 @@ export const NavigationProvider: React.FC<{
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []),
registerUnsavedChangesChecker: useCallback((checker: () => boolean) => {
unsavedChangesCheckerRef.current = checker;
}, []),
unregisterUnsavedChangesChecker: useCallback(() => {
unsavedChangesCheckerRef.current = null;
}, []),
showNavigationWarning: useCallback((show: boolean) => {
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
}, []),
@@ -289,8 +254,6 @@ export const useNavigationGuard = () => {
confirmNavigation: actions.confirmNavigation,
cancelNavigation: actions.cancelNavigation,
setHasUnsavedChanges: actions.setHasUnsavedChanges,
setShowNavigationWarning: actions.showNavigationWarning,
registerUnsavedChangesChecker: actions.registerUnsavedChangesChecker,
unregisterUnsavedChangesChecker: actions.unregisterUnsavedChangesChecker
setShowNavigationWarning: actions.showNavigationWarning
};
};
@@ -1,73 +0,0 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { preferencesService, UserPreferences, DEFAULT_PREFERENCES } from '../services/preferencesService';
interface PreferencesContextValue {
preferences: UserPreferences;
updatePreference: <K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
) => Promise<void>;
resetPreferences: () => Promise<void>;
isLoading: boolean;
}
const PreferencesContext = createContext<PreferencesContextValue | undefined>(undefined);
export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [preferences, setPreferences] = useState<UserPreferences>(DEFAULT_PREFERENCES);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadPreferences = async () => {
try {
await preferencesService.initialize();
const loadedPreferences = await preferencesService.getAllPreferences();
setPreferences(loadedPreferences);
} catch (error) {
console.error('Failed to load preferences:', error);
// Keep default preferences on error
} finally {
setIsLoading(false);
}
};
loadPreferences();
}, []);
const updatePreference = useCallback(
async <K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
await preferencesService.setPreference(key, value);
setPreferences((prev) => ({
...prev,
[key]: value,
}));
},
[]
);
const resetPreferences = useCallback(async () => {
await preferencesService.clearAllPreferences();
setPreferences(DEFAULT_PREFERENCES);
}, []);
return (
<PreferencesContext.Provider
value={{
preferences,
updatePreference,
resetPreferences,
isLoading,
}}
>
{children}
</PreferencesContext.Provider>
);
};
export const usePreferences = (): PreferencesContextValue => {
const context = useContext(PreferencesContext);
if (!context) {
throw new Error('usePreferences must be used within a PreferencesProvider');
}
return context;
};
@@ -73,10 +73,10 @@ function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowActio
// Context value interface
interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: ToolId | null;
selectedToolKey: string | null;
selectedTool: ToolRegistryEntry | null;
toolRegistry: Partial<ToolRegistry>;
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
toolRegistry: Record<string, ToolRegistryEntry>;
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
@@ -101,7 +101,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
handleReaderToggle: () => void;
// Computed values
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
isPanelVisible: boolean;
}
+2 -3
View File
@@ -51,9 +51,8 @@ function processFileSwap(
}
});
// Clear selections that reference removed files and add new files to selection
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
const newSelectedFileIds = [...validSelectedFileIds, ...addedIds];
return {
...state,
@@ -63,7 +62,7 @@ function processFileSwap(
},
ui: {
...state.ui,
selectedFileIds: newSelectedFileIds
selectedFileIds: validSelectedFileIds
}
};
}
+4 -18
View File
@@ -57,13 +57,13 @@ const addFilesMutex = new SimpleMutex();
/**
* Helper to create ProcessedFile metadata structure
*/
export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) {
export function createProcessedFile(pageCount: number, thumbnail?: string) {
return {
totalPages: pageCount,
pages: Array.from({ length: pageCount }, (_, index) => ({
pageNumber: index + 1,
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
rotation: pageRotations?.[index] ?? 0,
rotation: 0,
splitBefore: false
})),
thumbnailUrl: thumbnail,
@@ -82,22 +82,8 @@ export async function generateProcessedFileMetadata(file: File): Promise<Process
}
try {
// Generate unrotated thumbnails for PageEditor (rotation applied via CSS)
const unrotatedResult = await generateThumbnailWithMetadata(file, false);
// Generate rotated thumbnail for file manager display
const rotatedResult = await generateThumbnailWithMetadata(file, true);
const processedFile = createProcessedFile(
unrotatedResult.pageCount,
unrotatedResult.thumbnail, // Page thumbnails (unrotated)
unrotatedResult.pageRotations
);
// Use rotated thumbnail for file manager
processedFile.thumbnailUrl = rotatedResult.thumbnail;
return processedFile;
const result = await generateThumbnailWithMetadata(file);
return createProcessedFile(result.pageCount, result.thumbnail);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
}
@@ -14,9 +14,6 @@ import ReorganizePages from "../tools/ReorganizePages";
import { reorganizePagesOperationConfig } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import AdjustContrast from "../tools/AdjustContrast";
import AdjustContrastSingleStepSettings from "../components/tools/adjustContrast/AdjustContrastSingleStepSettings";
import { adjustContrastOperationConfig } from "../hooks/tools/adjustContrast/useAdjustContrastOperation";
import { getSynonyms } from "../utils/toolSynonyms";
import AddWatermark from "../tools/AddWatermark";
import AddStamp from "../tools/AddStamp";
@@ -668,13 +665,12 @@ export function useFlatToolRegistry(): ToolRegistry {
adjustContrast: {
icon: <LocalIcon icon="palette" width="1.5rem" height="1.5rem" />,
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
component: AdjustContrast,
component: null,
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
operationConfig: adjustContrastOperationConfig,
automationSettings: AdjustContrastSingleStepSettings,
synonyms: getSynonyms(t, "adjustContrast"),
automationSettings: null,
},
repair: {
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
@@ -824,7 +820,7 @@ export function useFlatToolRegistry(): ToolRegistry {
name: t("home.compare.title", "Compare"),
component: null,
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
synonyms: getSynonyms(t, "compare"),
supportsAutomate: false,
@@ -1,94 +0,0 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { AdjustContrastParameters, defaultParameters } from './useAdjustContrastParameters';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { applyAdjustmentsToCanvas } from '../../../components/tools/adjustContrast/utils';
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise<HTMLCanvasElement> {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D context unavailable');
await page.render({ canvasContext: ctx, viewport }).promise;
return canvas;
}
// adjustment logic moved to shared util
// Render, adjust, and assemble all pages of a single PDF into a new PDF
async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise<File> {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
const pageCount = pdf.numPages;
const newDoc = await PDFLibDocument.create();
for (let p = 1; p <= pageCount; p++) {
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
const pngUrl = adjusted.toDataURL('image/png');
const res = await fetch(pngUrl);
const pngBytes = new Uint8Array(await res.arrayBuffer());
const embedded = await newDoc.embedPng(pngBytes);
const { width, height } = embedded.scale(1);
const page = newDoc.addPage([width, height]);
page.drawImage(embedded, { x: 0, y: 0, width, height });
}
const pdfBytes = await newDoc.save();
const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
pdfWorkerManager.destroyDocument(pdf);
return out;
}
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<File[]> {
// Limit concurrency to avoid exhausting memory/CPU while still getting speedups
// Heuristic: use up to 4 workers on capable machines, otherwise 2-3
let CONCURRENCY_LIMIT = 2;
if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
else if (navigator.hardwareConcurrency >= 4) CONCURRENCY_LIMIT = 3;
}
CONCURRENCY_LIMIT = Math.min(CONCURRENCY_LIMIT, files.length);
const mapWithConcurrency = async <T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> => {
const results: R[] = new Array(items.length);
let nextIndex = 0;
const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
let current = nextIndex++;
while (current < items.length) {
results[current] = await worker(items[current], current);
current = nextIndex++;
}
});
await Promise.all(workers);
return results;
};
return mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params));
}
export const adjustContrastOperationConfig = {
toolType: ToolType.custom,
customProcessor: processPdfClientSide,
operationType: 'adjustContrast',
defaultParameters,
// Single-step settings component for Automate
settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
} as const;
export const useAdjustContrastOperation = () => {
const { t } = useTranslation();
return useToolOperation<AdjustContrastParameters>({
...adjustContrastOperationConfig,
getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
});
};
@@ -1,30 +0,0 @@
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface AdjustContrastParameters {
contrast: number; // 0-200 (%), 100 = neutral
brightness: number; // 0-200 (%), 100 = neutral
saturation: number; // 0-200 (%), 100 = neutral
red: number; // 0-200 (%), 100 = neutral
green: number; // 0-200 (%), 100 = neutral
blue: number; // 0-200 (%), 100 = neutral
}
export const defaultParameters: AdjustContrastParameters = {
contrast: 100,
brightness: 100,
saturation: 100,
red: 100,
green: 100,
blue: 100,
};
export type AdjustContrastParametersHook = BaseParametersHook<AdjustContrastParameters>;
export const useAdjustContrastParameters = (): AdjustContrastParametersHook => {
return useBaseParameters<AdjustContrastParameters>({
defaultParameters,
endpointName: '',
});
};
@@ -1,9 +1,8 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
import { useToolResources } from '../shared/useToolResources';
import { zipFileService } from '../../../services/zipFileService';
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
const formData = new FormData();
@@ -16,46 +15,40 @@ export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitPara
return formData;
};
// Static configuration object
// Custom response handler to handle ZIP files that might be misidentified
const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise<File[]> => {
try {
// Always try to extract as ZIP first, regardless of content-type
const extractionResult = await zipFileService.extractAllFiles(responseData);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
return extractionResult.extractedFiles;
}
} catch (error) {
console.warn('Failed to extract as ZIP, treating as single file:', error);
}
// Fallback: treat as single file (PNG image)
const inputFileName = inputFiles[0]?.name || 'document';
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' });
return [singleFile];
};
export const scannerImageSplitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildScannerImageSplitFormData,
operationType: 'scannerImageSplit',
endpoint: '/api/v1/misc/extract-image-scans',
multiFileEndpoint: false,
responseHandler: scannerImageSplitResponseHandler,
defaultParameters,
} as const;
export const useScannerImageSplitOperation = () => {
const { t } = useTranslation();
const { extractAllZipFiles } = useToolResources();
// Custom response handler that extracts ZIP files containing images
// Can't add to exported config because it requires access to the hook so must be part of the hook
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
try {
// Scanner image split returns ZIP files with multiple images
const extractedFiles = await extractAllZipFiles(blob);
// If extraction succeeded and returned files, use them
if (extractedFiles.length > 0) {
return extractedFiles;
}
} catch (error) {
console.warn('Failed to extract as ZIP, treating as single file:', error);
}
// Fallback: treat as single file (PNG image)
const inputFileName = originalFiles[0]?.name || 'document';
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
const singleFile = new File([blob], `${baseFileName}.png`, { type: 'image/png' });
return [singleFile];
}, [extractAllZipFiles]);
const config: ToolOperationConfig<ScannerImageSplitParameters> = {
return useToolOperation<ScannerImageSplitParameters>({
...scannerImageSplitOperationConfig,
responseHandler,
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
};
return useToolOperation(config);
});
};
@@ -257,7 +257,6 @@ export const useToolOperation = <TParams>(
processedFiles = [singleFile];
} else {
// Default: assume ZIP response for multi-file endpoints
// Note: extractZipFiles will check preferences.autoUnzip setting
processedFiles = await extractZipFiles(response.data);
if (processedFiles.length === 0) {
@@ -1,11 +1,9 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { zipFileService } from '../../../services/zipFileService';
import { usePreferences } from '../../../contexts/PreferencesContext';
export const useToolResources = () => {
const { preferences } = usePreferences();
const [blobUrls, setBlobUrls] = useState<string[]>([]);
const addBlobUrl = useCallback((url: string) => {
@@ -83,20 +81,8 @@ export const useToolResources = () => {
return results;
}, []);
const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
try {
// Check if we should extract based on preferences
const shouldExtract = await zipFileService.shouldUnzip(
zipBlob,
preferences.autoUnzip,
preferences.autoUnzipFileLimit,
skipAutoUnzip
);
if (!shouldExtract) {
return [new File([zipBlob], 'result.zip', { type: 'application/zip' })];
}
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
return extractionResult.success ? extractionResult.extractedFiles : [];
@@ -104,30 +90,32 @@ export const useToolResources = () => {
console.error('useToolResources.extractZipFiles - Error:', error);
return [];
}
}, [preferences.autoUnzip, preferences.autoUnzipFileLimit]);
}, []);
const extractAllZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise<File[]> => {
const extractAllZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
try {
// Check if we should extract based on preferences
const shouldExtract = await zipFileService.shouldUnzip(
zipBlob,
preferences.autoUnzip,
preferences.autoUnzipFileLimit,
skipAutoUnzip
);
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
if (!shouldExtract) {
return [new File([zipBlob], 'result.zip', { type: 'application/zip' })];
const arrayBuffer = await zipBlob.arrayBuffer();
const zipContent = await zip.loadAsync(arrayBuffer);
const extractedFiles: File[] = [];
for (const [filename, file] of Object.entries(zipContent.files)) {
if (!file.dir) {
const content = await file.async('blob');
const extractedFile = new File([content], filename, { type: 'application/pdf' });
extractedFiles.push(extractedFile);
}
}
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
const extractionResult = await zipFileService.extractAllFiles(zipFile);
return extractionResult.success ? extractionResult.extractedFiles : [];
return extractedFiles;
} catch (error) {
console.error('useToolResources.extractAllZipFiles - Error:', error);
console.error('Error in extractAllZipFiles:', error);
return [];
}
}, [preferences.autoUnzip, preferences.autoUnzipFileLimit]);
}, []);
const createDownloadInfo = useCallback(async (
files: File[],
@@ -17,7 +17,6 @@ export interface SignParameters {
signerName?: string;
fontFamily?: string;
fontSize?: number;
textColor?: string;
}
export const DEFAULT_PARAMETERS: SignParameters = {
@@ -27,7 +26,6 @@ export const DEFAULT_PARAMETERS: SignParameters = {
signerName: '',
fontFamily: 'Helvetica',
fontSize: 16,
textColor: '#000000',
};
const validateSignParameters = (parameters: SignParameters): boolean => {
+1
View File
@@ -21,6 +21,7 @@ export interface AppConfig {
runningProOrHigher?: boolean;
runningEE?: boolean;
license?: string;
GoogleDriveEnabled?: boolean;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
error?: string;
@@ -1,98 +0,0 @@
/**
* React hook for Google Drive file picker
*/
import { useState, useCallback, useEffect } from 'react';
import {
getGoogleDrivePickerService,
isGoogleDriveConfigured,
getGoogleDriveConfig,
} from '../services/googleDrivePickerService';
interface UseGoogleDrivePickerOptions {
multiple?: boolean;
mimeTypes?: string;
}
interface UseGoogleDrivePickerReturn {
isEnabled: boolean;
isLoading: boolean;
error: string | null;
openPicker: (options?: UseGoogleDrivePickerOptions) => Promise<File[]>;
}
/**
* Hook to use Google Drive file picker
*/
export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn {
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
// Check if Google Drive is configured on mount
useEffect(() => {
const configured = isGoogleDriveConfigured();
setIsEnabled(configured);
}, []);
/**
* Initialize the Google Drive service (lazy initialization)
*/
const initializeService = useCallback(async () => {
if (isInitialized) return;
const config = getGoogleDriveConfig();
if (!config) {
throw new Error('Google Drive is not configured');
}
const service = getGoogleDrivePickerService();
await service.initialize(config);
setIsInitialized(true);
}, [isInitialized]);
/**
* Open the Google Drive picker
*/
const openPicker = useCallback(
async (options: UseGoogleDrivePickerOptions = {}): Promise<File[]> => {
if (!isEnabled) {
setError('Google Drive is not configured');
return [];
}
try {
setIsLoading(true);
setError(null);
// Initialize service if needed
await initializeService();
// Open picker
const service = getGoogleDrivePickerService();
const files = await service.openPicker({
multiple: options.multiple ?? true,
mimeTypes: options.mimeTypes,
});
return files;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to open Google Drive picker';
setError(errorMessage);
console.error('Google Drive picker error:', err);
return [];
} finally {
setIsLoading(false);
}
},
[isEnabled, initializeService]
);
return {
isEnabled,
isLoading,
error,
openPicker,
};
}
+4 -5
View File
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useToolNavigation } from './useToolNavigation';
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
import { ToolId } from 'src/types/toolId';
export interface SidebarNavigationProps {
/** Full URL for the navigation (for href attribute) */
@@ -17,7 +16,7 @@ export interface SidebarNavigationProps {
*/
export function useSidebarNavigation(): {
getHomeNavigation: () => SidebarNavigationProps;
getToolNavigation: (toolId: ToolId) => SidebarNavigationProps | null;
getToolNavigation: (toolId: string) => SidebarNavigationProps | null;
} {
const { getToolNavigation: getToolNavProps } = useToolNavigation();
const { getSelectedTool } = useToolWorkflow();
@@ -33,14 +32,14 @@ export function useSidebarNavigation(): {
return { href, onClick: defaultNavClick };
}, [defaultNavClick]);
const getToolNavigation = useCallback((toolId: ToolId): SidebarNavigationProps | null => {
const getToolNavigation = useCallback((toolId: string): SidebarNavigationProps | null => {
// Handle special nav sections that aren't tools
if (toolId === 'read') return { href: '/read', onClick: defaultNavClick };
if (toolId === 'automate') return { href: '/automate', onClick: defaultNavClick };
const tool = getSelectedTool(toolId);
if (!tool) return null;
// Delegate to useToolNavigation for true tools
return getToolNavProps(toolId, tool);
}, [getToolNavProps, getSelectedTool, defaultNavClick]);
@@ -49,4 +48,4 @@ export function useSidebarNavigation(): {
getHomeNavigation,
getToolNavigation
};
}
}
+7 -8
View File
@@ -1,17 +1,16 @@
import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "../data/toolsTaxonomy";
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { FileId } from '../types/file';
import { ToolId } from 'src/types/toolId';
interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null;
toolSelectedFileIds: FileId[];
toolRegistry: Partial<ToolRegistry>;
toolRegistry: Record<string, ToolRegistryEntry>;
setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
}
export const useToolManagement = (): ToolManagementResult => {
@@ -31,9 +30,9 @@ export const useToolManagement = (): ToolManagementResult => {
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
const toolRegistry: Record<string, ToolRegistryEntry> = useMemo(() => {
const availableToolRegistry: Record<string, ToolRegistryEntry> = {};
Object.keys(baseRegistry).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
availableToolRegistry[toolKey] = {
@@ -46,7 +45,7 @@ export const useToolManagement = (): ToolManagementResult => {
return availableToolRegistry;
}, [isToolAvailable, t, baseRegistry]);
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
const getSelectedTool = useCallback((toolKey: string | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;
}, [toolRegistry]);
+7 -8
View File
@@ -2,10 +2,9 @@ import { useMemo } from 'react';
import { SUBCATEGORY_ORDER, SubcategoryId, ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useTranslation } from 'react-i18next';
import { ToolId } from 'src/types/toolId';
type SubcategoryIdMap = {
[subcategoryId in SubcategoryId]: Array<{ id: ToolId; tool: ToolRegistryEntry }>;
[subcategoryId in SubcategoryId]: Array<{ id: string /* FIX ME: Should be ToolId */; tool: ToolRegistryEntry }>;
}
type GroupedTools = {
@@ -15,7 +14,7 @@ type GroupedTools = {
export interface SubcategoryGroup {
subcategoryId: SubcategoryId;
tools: {
id: ToolId;
id: string /* FIX ME: Should be ToolId */;
tool: ToolRegistryEntry;
}[];
};
@@ -29,7 +28,7 @@ export interface ToolSection {
};
export function useToolSections(
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>,
filteredTools: Array<{ item: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry]; matchedText?: string }>,
searchQuery?: string
) {
const { t } = useTranslation();
@@ -38,7 +37,7 @@ export function useToolSections(
if (!filteredTools || !Array.isArray(filteredTools)) {
return {} as GroupedTools;
}
const grouped = {} as GroupedTools;
filteredTools.forEach(({ item: [id, tool] }) => {
const categoryId = tool.categoryId;
@@ -106,11 +105,11 @@ export function useToolSections(
if (!filteredTools || !Array.isArray(filteredTools)) {
return [];
}
const subMap = {} as SubcategoryIdMap;
const seen = new Set<ToolId>();
const seen = new Set<string /* FIX ME: Should be ToolId */>();
filteredTools.forEach(({ item: [id, tool] }) => {
const toolId = id as ToolId;
const toolId = id as string /* FIX ME: Should be ToolId */;
if (seen.has(toolId)) return;
seen.add(toolId);
const sub = tool.subcategoryId;
@@ -10,8 +10,14 @@ export class DocumentManipulationService {
* Returns single document or multiple documents if splits are present
*/
applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set<number>): PDFDocument | PDFDocument[] {
console.log('DocumentManipulationService: Applying DOM changes to document');
console.log('Original document page order:', pdfDocument.pages.map(p => p.pageNumber));
console.log('Current display order:', currentDisplayOrder?.pages.map(p => p.pageNumber) || 'none provided');
console.log('Split positions:', splitPositions ? Array.from(splitPositions).sort() : 'none');
// Use current display order (from React state) if provided, otherwise use original order
const baseDocument = currentDisplayOrder || pdfDocument;
console.log('Using page order:', baseDocument.pages.map(p => p.pageNumber));
// Apply DOM changes to each page (rotation only now, splits are position-based)
let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page));
@@ -51,25 +57,32 @@ export class DocumentManipulationService {
private createSplitDocuments(document: PDFDocument): PDFDocument[] {
const documents: PDFDocument[] = [];
const splitPoints: number[] = [];
// Find split points
document.pages.forEach((page, index) => {
if (page.splitAfter) {
console.log(`Found split marker at page ${page.pageNumber} (index ${index}), adding split point at ${index + 1}`);
splitPoints.push(index + 1);
}
});
// Add end point if not already there
if (splitPoints.length === 0 || splitPoints[splitPoints.length - 1] !== document.pages.length) {
splitPoints.push(document.pages.length);
}
console.log('Final split points:', splitPoints);
console.log('Total pages to split:', document.pages.length);
let startIndex = 0;
let partNumber = 1;
for (const endIndex of splitPoints) {
const segmentPages = document.pages.slice(startIndex, endIndex);
console.log(`Creating split document ${partNumber}: pages ${startIndex}-${endIndex-1} (${segmentPages.length} pages)`);
console.log(`Split document ${partNumber} page numbers:`, segmentPages.map(p => p.pageNumber));
if (segmentPages.length > 0) {
documents.push({
...document,
@@ -80,10 +93,11 @@ export class DocumentManipulationService {
});
partNumber++;
}
startIndex = endIndex;
}
console.log(`Created ${documents.length} split documents`);
return documents;
}
@@ -94,6 +108,7 @@ export class DocumentManipulationService {
// Find the DOM element for this page
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (!pageElement) {
console.log(`Page ${page.pageNumber}: No DOM element found, keeping original state`);
return page;
}
@@ -101,7 +116,8 @@ export class DocumentManipulationService {
// Apply rotation changes from DOM
updatedPage.rotation = this.getRotationFromDOM(pageElement, page);
return updatedPage;
}
@@ -110,21 +126,16 @@ export class DocumentManipulationService {
*/
private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number {
const img = pageElement.querySelector('img');
if (img) {
const originalRotation = parseInt(img.getAttribute('data-original-rotation') || '0');
const currentTransform = img.style.transform || '';
const rotationMatch = currentTransform.match(/rotate\((-?\d+)deg\)/);
const visualRotation = rotationMatch ? parseInt(rotationMatch[1]) : originalRotation;
const userChange = ((visualRotation - originalRotation) % 360 + 360) % 360;
let finalRotation = (originalPage.rotation + userChange) % 360;
if (finalRotation === 360) finalRotation = 0;
return finalRotation;
if (img && img.style.transform) {
// Parse rotation from transform property (e.g., "rotate(90deg)" -> 90)
const rotationMatch = img.style.transform.match(/rotate\((-?\d+)deg\)/);
const domRotation = rotationMatch ? parseInt(rotationMatch[1]) : 0;
console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`);
return domRotation;
}
console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`);
return originalPage.rotation;
}
@@ -200,13 +200,11 @@ export class EnhancedPDFProcessingService {
const page = await pdf.getPage(i);
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
const rotation = page.rotate || 0;
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail,
rotation,
rotation: 0,
selected: false
});
@@ -256,7 +254,7 @@ export class EnhancedPDFProcessingService {
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: page.rotate || 0,
rotation: 0,
selected: false
});
@@ -267,15 +265,11 @@ export class EnhancedPDFProcessingService {
// Create placeholder pages for remaining pages
for (let i = priorityCount + 1; i <= totalPages; i++) {
// Load page just to get rotation
const page = await pdf.getPage(i);
const rotation = page.rotate || 0;
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null, // Will be loaded lazily
rotation,
rotation: 0,
selected: false
});
}
@@ -322,7 +316,7 @@ export class EnhancedPDFProcessingService {
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: page.rotate || 0,
rotation: 0,
selected: false
});
@@ -339,15 +333,11 @@ export class EnhancedPDFProcessingService {
// Create placeholders for remaining pages
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
// Load page just to get rotation
const page = await pdf.getPage(i);
const rotation = page.rotate || 0;
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation,
rotation: 0,
selected: false
});
}
@@ -377,15 +367,11 @@ export class EnhancedPDFProcessingService {
// Create placeholder pages without thumbnails
const pages: PDFPage[] = [];
for (let i = 1; i <= totalPages; i++) {
// Load page just to get rotation
const page = await pdf.getPage(i);
const rotation = page.rotate || 0;
pages.push({
id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i,
thumbnail: null,
rotation,
rotation: 0,
selected: false
});
}
@@ -404,7 +390,7 @@ export class EnhancedPDFProcessingService {
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
const scale = scales[quality];
const viewport = page.getViewport({ scale, rotation: 0 });
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
-34
View File
@@ -1,34 +0,0 @@
import { StirlingFile, StirlingFileStub } from '../types/fileContext';
import { createChildStub, generateProcessedFileMetadata } from '../contexts/file/fileActions';
import { createStirlingFile } from '../types/fileContext';
import { ToolId } from '../types/toolId';
/**
* Create StirlingFiles and StirlingFileStubs from exported files
* Used when saving page editor changes to create version history
*/
export async function createStirlingFilesAndStubs(
files: File[],
parentStub: StirlingFileStub,
toolId: ToolId
): Promise<{ stirlingFiles: StirlingFile[], stubs: StirlingFileStub[] }> {
const stirlingFiles: StirlingFile[] = [];
const stubs: StirlingFileStub[] = [];
for (const file of files) {
const processedFileMetadata = await generateProcessedFileMetadata(file);
const childStub = createChildStub(
parentStub,
{ toolId, timestamp: Date.now() },
file,
processedFileMetadata?.thumbnailUrl,
processedFileMetadata
);
const stirlingFile = createStirlingFile(file, childStub.id);
stirlingFiles.push(stirlingFile);
stubs.push(childStub);
}
return { stirlingFiles, stubs };
}
@@ -1,295 +0,0 @@
/**
* Google Drive Picker Service
* Handles Google Drive file picker integration
*/
import { loadScript } from '../utils/scriptLoader';
const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken';
interface GoogleDriveConfig {
clientId: string;
apiKey: string;
appId: string;
}
interface PickerOptions {
multiple?: boolean;
mimeTypes?: string | null;
}
// Expandable mime types for Google Picker
const expandableMimeTypes: Record<string, string[]> = {
'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'],
};
/**
* Convert file input accept attribute to Google Picker mime types
*/
function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
if (!accept || accept === '' || accept.includes('*/*')) {
// Setting null will accept all supported mimetypes
return null;
}
const mimeTypes: string[] = [];
accept.split(',').forEach((part) => {
const trimmedPart = part.trim();
if (!(trimmedPart in expandableMimeTypes)) {
mimeTypes.push(trimmedPart);
return;
}
expandableMimeTypes[trimmedPart].forEach((mimeType) => {
mimeTypes.push(mimeType);
});
});
return mimeTypes.join(',').replace(/\s+/g, '');
}
class GoogleDrivePickerService {
private config: GoogleDriveConfig | null = null;
private tokenClient: any = null;
private accessToken: string | null = null;
private gapiLoaded = false;
private gisLoaded = false;
constructor() {
this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID);
}
/**
* Initialize the service with credentials
*/
async initialize(config: GoogleDriveConfig): Promise<void> {
this.config = config;
// Load Google APIs
await Promise.all([
this.loadGapi(),
this.loadGis(),
]);
}
/**
* Load Google API client
*/
private async loadGapi(): Promise<void> {
if (this.gapiLoaded) return;
await loadScript({
src: 'https://apis.google.com/js/api.js',
id: 'gapi-script',
});
return new Promise((resolve) => {
window.gapi.load('client:picker', async () => {
await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest');
this.gapiLoaded = true;
resolve();
});
});
}
/**
* Load Google Identity Services
*/
private async loadGis(): Promise<void> {
if (this.gisLoaded) return;
await loadScript({
src: 'https://accounts.google.com/gsi/client',
id: 'gis-script',
});
if (!this.config) {
throw new Error('Google Drive config not initialized');
}
this.tokenClient = window.google.accounts.oauth2.initTokenClient({
client_id: this.config.clientId,
scope: SCOPES,
callback: () => {}, // Will be overridden during picker creation
});
this.gisLoaded = true;
}
/**
* Open the Google Drive picker
*/
async openPicker(options: PickerOptions = {}): Promise<File[]> {
if (!this.config) {
throw new Error('Google Drive service not initialized');
}
// Request access token
await this.requestAccessToken();
// Create and show picker
return this.createPicker(options);
}
/**
* Request access token from Google
*/
private requestAccessToken(): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.tokenClient) {
reject(new Error('Token client not initialized'));
return;
}
this.tokenClient.callback = (response: any) => {
if (response.error !== undefined) {
reject(new Error(response.error));
return;
}
if(response.access_token == null){
reject(new Error("No acces token in response"));
}
this.accessToken = response.access_token;
sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? "");
resolve();
};
this.tokenClient.requestAccessToken({
prompt: this.accessToken === null ? 'consent' : '',
});
});
}
/**
* Create and display the Google Picker
*/
private createPicker(options: PickerOptions): Promise<File[]> {
return new Promise((resolve, reject) => {
if (!this.config || !this.accessToken) {
reject(new Error('Not initialized or no access token'));
return;
}
const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined);
const view1 = new window.google.picker.DocsView().setIncludeFolders(true);
if (mimeTypes !== null) {
view1.setMimeTypes(mimeTypes);
}
const view2 = new window.google.picker.DocsView()
.setIncludeFolders(true)
.setEnableDrives(true);
if (mimeTypes !== null) {
view2.setMimeTypes(mimeTypes);
}
const builder = new window.google.picker.PickerBuilder()
.setDeveloperKey(this.config.apiKey)
.setAppId(this.config.appId)
.setOAuthToken(this.accessToken)
.addView(view1)
.addView(view2)
.setCallback((data: any) => this.pickerCallback(data, resolve, reject));
if (options.multiple) {
builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
}
const picker = builder.build();
picker.setVisible(true);
});
}
/**
* Handle picker selection callback
*/
private async pickerCallback(
data: any,
resolve: (files: File[]) => void,
reject: (error: Error) => void
): Promise<void> {
if (data.action === window.google.picker.Action.PICKED) {
try {
const files = await Promise.all(
data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => {
const fileId = pickedFile[window.google.picker.Document.ID];
const res = await window.gapi.client.drive.files.get({
fileId: fileId,
alt: 'media',
});
// Convert response body to File object
const file = new File(
[new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))],
pickedFile.name,
{
type: pickedFile.mimeType,
lastModified: pickedFile.lastModified,
}
);
return file;
})
);
resolve(files);
} catch (error) {
reject(error instanceof Error ? error : new Error('Failed to download files'));
}
} else if (data.action === window.google.picker.Action.CANCEL) {
resolve([]); // User cancelled, return empty array
}
}
/**
* Sign out and revoke access token
*/
signOut(): void {
if (this.accessToken) {
sessionStorage.removeItem(SESSION_STORAGE_ID);
window.google?.accounts.oauth2.revoke(this.accessToken, () => {});
this.accessToken = null;
}
}
}
// Singleton instance
let serviceInstance: GoogleDrivePickerService | null = null;
/**
* Get or create the Google Drive picker service instance
*/
export function getGoogleDrivePickerService(): GoogleDrivePickerService {
if (!serviceInstance) {
serviceInstance = new GoogleDrivePickerService();
}
return serviceInstance;
}
/**
* Check if Google Drive credentials are configured
*/
export function isGoogleDriveConfigured(): boolean {
const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
return !!(clientId && apiKey && appId);
}
/**
* Get Google Drive configuration from environment variables
*/
export function getGoogleDriveConfig(): GoogleDriveConfig | null {
if (!isGoogleDriveConfigured()) {
return null;
}
return {
clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID,
apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY,
appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID,
};
}
@@ -314,15 +314,6 @@ export const DATABASE_CONFIGS = {
}]
} as DatabaseConfig,
PREFERENCES: {
name: 'stirling-pdf-preferences',
version: 1,
stores: [{
name: 'preferences',
keyPath: 'key'
}]
} as DatabaseConfig,
} as const;
export const indexedDBManager = IndexedDBManager.getInstance();
-46
View File
@@ -1,46 +0,0 @@
import { PDFDocument } from '../types/pageEditor';
import { pdfExportService } from './pdfExportService';
import { FileId } from '../types/file';
/**
* Export processed documents to File objects
* Handles both single documents and split documents (multiple PDFs)
*/
export async function exportProcessedDocumentsToFiles(
processedDocuments: PDFDocument | PDFDocument[],
sourceFiles: Map<FileId, File> | null,
exportFilename: string
): Promise<File[]> {
console.log('exportProcessedDocumentsToFiles called with:', {
isArray: Array.isArray(processedDocuments),
numDocs: Array.isArray(processedDocuments) ? processedDocuments.length : 1,
hasSourceFiles: sourceFiles !== null,
sourceFilesSize: sourceFiles?.size
});
if (Array.isArray(processedDocuments)) {
// Multiple documents (splits)
const files: File[] = [];
const baseName = exportFilename.replace(/\.pdf$/i, '');
for (let i = 0; i < processedDocuments.length; i++) {
const doc = processedDocuments[i];
const partFilename = `${baseName}_part_${i + 1}.pdf`;
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { selectedOnly: false, filename: partFilename })
: await pdfExportService.exportPDF(doc, [], { selectedOnly: false, filename: partFilename });
files.push(new File([result.blob], result.filename, { type: 'application/pdf' }));
}
return files;
} else {
// Single document
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(processedDocuments, sourceFiles, [], { selectedOnly: false, filename: exportFilename })
: await pdfExportService.exportPDF(processedDocuments, [], { selectedOnly: false, filename: exportFilename });
return [new File([result.blob], result.filename, { type: 'application/pdf' })];
}
}
+16 -4
View File
@@ -98,7 +98,10 @@ export class PDFExportService {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
blankPage.setRotation(degrees(page.rotation));
// Apply rotation if needed
if (page.rotation !== 0) {
blankPage.setRotation(degrees(page.rotation));
}
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
// Get the correct source document for this page
const sourceDoc = loadedDocs.get(page.originalFileId)!;
@@ -108,7 +111,10 @@ export class PDFExportService {
// Copy the page from the correct source document
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
copiedPage.setRotation(degrees(page.rotation));
// Apply rotation
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
newDoc.addPage(copiedPage);
}
@@ -141,7 +147,10 @@ export class PDFExportService {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
blankPage.setRotation(degrees(page.rotation));
// Apply rotation if needed
if (page.rotation !== 0) {
blankPage.setRotation(degrees(page.rotation));
}
} else {
// Get the original page from source document using originalPageNumber
const sourcePageIndex = page.originalPageNumber - 1;
@@ -150,7 +159,10 @@ export class PDFExportService {
// Copy the page
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
copiedPage.setRotation(degrees(page.rotation));
// Apply rotation
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
newDoc.addPage(copiedPage);
}
-129
View File
@@ -1,129 +0,0 @@
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
export interface UserPreferences {
autoUnzip: boolean;
autoUnzipFileLimit: number;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
autoUnzip: true,
autoUnzipFileLimit: 4,
};
class PreferencesService {
private db: IDBDatabase | null = null;
async initialize(): Promise<void> {
this.db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.PREFERENCES);
}
private ensureDatabase(): IDBDatabase {
if (!this.db) {
throw new Error('PreferencesService not initialized. Call initialize() first.');
}
return this.db;
}
async getPreference<K extends keyof UserPreferences>(
key: K
): Promise<UserPreferences[K]> {
const db = this.ensureDatabase();
return new Promise((resolve) => {
const transaction = db.transaction(['preferences'], 'readonly');
const store = transaction.objectStore('preferences');
const request = store.get(key);
request.onsuccess = () => {
const result = request.result;
if (result && result.value !== undefined) {
resolve(result.value);
} else {
// Return default value if preference not found
resolve(DEFAULT_PREFERENCES[key]);
}
};
request.onerror = () => {
console.error('Error reading preference:', key, request.error);
// Return default value on error
resolve(DEFAULT_PREFERENCES[key]);
};
});
}
async setPreference<K extends keyof UserPreferences>(
key: K,
value: UserPreferences[K]
): Promise<void> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readwrite');
const store = transaction.objectStore('preferences');
const request = store.put({ key, value });
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
console.error('Error writing preference:', key, request.error);
reject(request.error);
};
});
}
async getAllPreferences(): Promise<UserPreferences> {
const db = this.ensureDatabase();
return new Promise((resolve) => {
const transaction = db.transaction(['preferences'], 'readonly');
const store = transaction.objectStore('preferences');
const request = store.getAll();
request.onsuccess = () => {
const storedPrefs: Partial<UserPreferences> = {};
const results = request.result;
for (const item of results) {
if (item.key && item.value !== undefined) {
storedPrefs[item.key as keyof UserPreferences] = item.value;
}
}
// Merge with defaults to ensure all preferences exist
resolve({
...DEFAULT_PREFERENCES,
...storedPrefs,
});
};
request.onerror = () => {
console.error('Error reading all preferences:', request.error);
// Return defaults on error
resolve({ ...DEFAULT_PREFERENCES });
};
});
}
async clearAllPreferences(): Promise<void> {
const db = this.ensureDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(['preferences'], 'readwrite');
const store = transaction.objectStore('preferences');
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error);
};
});
}
}
export const preferencesService = new PreferencesService();
@@ -164,7 +164,7 @@ export class ThumbnailGenerationService {
for (const pageNumber of batch) {
try {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale, rotation: 0 });
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
+32 -132
View File
@@ -1,7 +1,4 @@
import JSZip, { JSZipObject } from 'jszip';
import { StirlingFileStub, createStirlingFile } from '../types/fileContext';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
import { fileStorage } from './fileStorage';
// Undocumented interface in JSZip for JSZipObject._data
interface CompressedObject {
@@ -44,15 +41,6 @@ export class ZipFileService {
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
private readonly supportedExtensions = ['.pdf'];
// ZIP file validation constants
private static readonly VALID_ZIP_TYPES = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'application/octet-stream' // Some browsers use this for ZIP files
];
private static readonly VALID_ZIP_EXTENSIONS = ['.zip'];
/**
* Validate a ZIP file without extracting it
*/
@@ -250,27 +238,23 @@ export class ZipFileService {
/**
* Check if a file is a ZIP file based on type and extension
*/
public isZipFile(file: File): boolean {
const hasValidType = ZipFileService.VALID_ZIP_TYPES.includes(file.type);
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
private isZipFile(file: File): boolean {
const validTypes = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'application/octet-stream' // Some browsers use this for ZIP files
];
const validExtensions = ['.zip'];
const hasValidType = validTypes.includes(file.type);
const hasValidExtension = validExtensions.some(ext =>
file.name.toLowerCase().endsWith(ext)
);
return hasValidType || hasValidExtension;
}
/**
* Check if a StirlingFileStub represents a ZIP file (for UI checks without loading full file)
*/
public isZipFileStub(stub: StirlingFileStub): boolean {
const hasValidType = stub.type && ZipFileService.VALID_ZIP_TYPES.includes(stub.type);
const hasValidExtension = ZipFileService.VALID_ZIP_EXTENSIONS.some(ext =>
stub.name.toLowerCase().endsWith(ext)
);
return hasValidType || hasValidExtension;
}
/**
* Check if a filename indicates a PDF file
*/
@@ -325,44 +309,33 @@ export class ZipFileService {
}
/**
* Determine if a ZIP file should be extracted based on user preferences
*
* @param zipBlob - The ZIP file to check
* @param autoUnzip - User preference for auto-unzipping
* @param autoUnzipFileLimit - Maximum number of files to auto-extract
* @param skipAutoUnzip - Bypass preference check (for automation)
* @returns true if the ZIP should be extracted, false otherwise
* Get file extension from filename
*/
async shouldUnzip(
zipBlob: Blob | File,
autoUnzip: boolean,
autoUnzipFileLimit: number,
skipAutoUnzip: boolean = false
): Promise<boolean> {
private getFileExtension(filename: string): string {
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
}
/**
* Check if ZIP file contains password protection
*/
private async isPasswordProtected(file: File): Promise<boolean> {
try {
// Automation always extracts
if (skipAutoUnzip) {
return true;
}
// Check if auto-unzip is enabled
if (!autoUnzip) {
return false;
}
// Load ZIP and count files
const zip = new JSZip();
const zipContents = await zip.loadAsync(zipBlob);
await zip.loadAsync(file);
// Count non-directory entries
const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length;
// Check if any files are encrypted
for (const [_filename, zipEntry] of Object.entries(zip.files)) {
if (zipEntry.options?.compression === 'STORE' && getData(zipEntry)?.compressedSize === 0) {
// This might indicate encryption, but JSZip doesn't provide direct encryption detection
// We'll handle this in the extraction phase
}
}
// Only extract if within limit
return fileCount <= autoUnzipFileLimit;
return false; // JSZip will throw an error if password is required
} catch (error) {
console.error('Error checking shouldUnzip:', error);
// On error, default to not extracting (safer)
return false;
// If we can't load the ZIP, it might be password protected
const errorMessage = error instanceof Error ? error.message : '';
return errorMessage.includes('password') || errorMessage.includes('encrypted');
}
}
@@ -484,79 +457,6 @@ export class ZipFileService {
return mimeTypes[ext || ''] || 'application/octet-stream';
}
/**
* Extract PDF files from ZIP and store them in IndexedDB with preserved history metadata
* Used by both FileManager and FileEditor to avoid code duplication
*
* @param zipFile - The ZIP file to extract from
* @param zipStub - The StirlingFileStub for the ZIP (contains metadata to preserve)
* @returns Object with success status, extracted stubs, and any errors
*/
async extractAndStoreFilesWithHistory(
zipFile: File,
zipStub: StirlingFileStub
): Promise<{ success: boolean; extractedStubs: StirlingFileStub[]; errors: string[] }> {
const result = {
success: false,
extractedStubs: [] as StirlingFileStub[],
errors: [] as string[]
};
try {
// Extract PDF files from ZIP
const extractionResult = await this.extractPdfFiles(zipFile);
if (!extractionResult.success || extractionResult.extractedFiles.length === 0) {
result.errors = extractionResult.errors;
return result;
}
// Process each extracted file
for (const extractedFile of extractionResult.extractedFiles) {
try {
// Generate thumbnail
const thumbnail = await generateThumbnailForFile(extractedFile);
// Create StirlingFile
const newStirlingFile = createStirlingFile(extractedFile);
// Create StirlingFileStub with ZIP's history metadata
const stub: StirlingFileStub = {
id: newStirlingFile.fileId,
name: extractedFile.name,
size: extractedFile.size,
type: extractedFile.type,
lastModified: extractedFile.lastModified,
quickKey: newStirlingFile.quickKey,
createdAt: Date.now(),
isLeaf: true,
// Preserve ZIP's history - unzipping is NOT a tool operation
originalFileId: zipStub.originalFileId,
parentFileId: zipStub.parentFileId,
versionNumber: zipStub.versionNumber,
toolHistory: zipStub.toolHistory || [],
thumbnailUrl: thumbnail
};
// Store in IndexedDB
await fileStorage.storeStirlingFile(newStirlingFile, stub);
result.extractedStubs.push(stub);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to process "${extractedFile.name}": ${errorMessage}`);
}
}
result.success = result.extractedStubs.length > 0;
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to extract ZIP file: ${errorMessage}`);
return result;
}
}
}
// Export singleton instance
@@ -15,7 +15,6 @@ import { renderHook, act } from '@testing-library/react';
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
import { ConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
import { FileContextProvider } from '../../contexts/FileContext';
import { PreferencesProvider } from '../../contexts/PreferencesContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n/config';
import { createTestStirlingFile } from '../utils/testFileHelpers';
@@ -89,11 +88,9 @@ const createPDFFile = (): StirlingFile => {
// Test wrapper component
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<I18nextProvider i18n={i18n}>
<PreferencesProvider>
<FileContextProvider>
{children}
</FileContextProvider>
</PreferencesProvider>
<FileContextProvider>
{children}
</FileContextProvider>
</I18nextProvider>
);
@@ -9,7 +9,6 @@ import { renderHook, act, waitFor } from '@testing-library/react';
import { useConvertOperation } from '../../hooks/tools/convert/useConvertOperation';
import { useConvertParameters } from '../../hooks/tools/convert/useConvertParameters';
import { FileContextProvider } from '../../contexts/FileContext';
import { PreferencesProvider } from '../../contexts/PreferencesContext';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n/config';
import { detectFileExtension } from '../../utils/fileUtils';
@@ -77,11 +76,9 @@ vi.mock('../../services/thumbnailGenerationService', () => ({
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<I18nextProvider i18n={i18n}>
<PreferencesProvider>
<FileContextProvider>
{children}
</FileContextProvider>
</PreferencesProvider>
<FileContextProvider>
{children}
</FileContextProvider>
</I18nextProvider>
);
-121
View File
@@ -1,121 +0,0 @@
import { useTranslation } from 'react-i18next';
import React, { useEffect, useMemo, useState } from 'react';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import { BaseToolProps, ToolComponent } from '../types/tool';
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
import { useAdjustContrastParameters } from '../hooks/tools/adjustContrast/useAdjustContrastParameters';
import { useAdjustContrastOperation } from '../hooks/tools/adjustContrast/useAdjustContrastOperation';
import AdjustContrastBasicSettings from '../components/tools/adjustContrast/AdjustContrastBasicSettings';
import AdjustContrastColorSettings from '../components/tools/adjustContrast/AdjustContrastColorSettings';
import AdjustContrastPreview from '../components/tools/adjustContrast/AdjustContrastPreview';
import { useAccordionSteps } from '../hooks/tools/shared/useAccordionSteps';
import NavigationArrows from '../components/shared/filePreview/NavigationArrows';
const AdjustContrast = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'adjustContrast',
useAdjustContrastParameters,
useAdjustContrastOperation,
props
);
enum Step { NONE='none', BASIC='basic', COLORS='colors' }
const accordion = useAccordionSteps<Step>({
noneValue: Step.NONE,
initialStep: Step.BASIC,
stateConditions: { hasFiles: base.hasFiles, hasResults: base.hasResults },
afterResults: base.handleSettingsReset
});
// Track which selected file is being previewed. Clamp when selection changes.
const [previewIndex, setPreviewIndex] = useState(0);
const totalSelected = base.selectedFiles.length;
useEffect(() => {
if (previewIndex >= totalSelected) {
setPreviewIndex(Math.max(0, totalSelected - 1));
}
}, [totalSelected, previewIndex]);
const currentFile = useMemo(() => {
return totalSelected > 0 ? base.selectedFiles[previewIndex] : null;
}, [base.selectedFiles, previewIndex, totalSelected]);
const handlePrev = () => setPreviewIndex((i) => Math.max(0, i - 1));
const handleNext = () => setPreviewIndex((i) => Math.min(totalSelected - 1, i + 1));
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t('adjustContrast.basic', 'Basic Adjustments'),
isCollapsed: accordion.getCollapsedState(Step.BASIC),
onCollapsedClick: () => accordion.handleStepToggle(Step.BASIC),
content: (
<AdjustContrastBasicSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
{
title: t('adjustContrast.adjustColors', 'Adjust Colors'),
isCollapsed: accordion.getCollapsedState(Step.COLORS),
onCollapsedClick: () => accordion.handleStepToggle(Step.COLORS),
content: (
<AdjustContrastColorSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
preview: (
<div>
<NavigationArrows
onPrevious={handlePrev}
onNext={handleNext}
disabled={totalSelected <= 1}
>
<div style={{ width: '100%' }}>
<AdjustContrastPreview
file={currentFile || null}
parameters={base.params.parameters}
/>
</div>
</NavigationArrows>
{totalSelected > 1 && (
<div style={{ textAlign: 'center', marginTop: 8, fontSize: 12, color: 'var(--text-color-muted)' }}>
{`${previewIndex + 1} of ${totalSelected}`}
</div>
)}
</div>
),
executeButton: {
text: t('adjustContrast.confirm', 'Confirm'),
isVisible: !base.hasResults,
loadingText: t('loading'),
onClick: base.handleExecute,
disabled: !base.hasFiles,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t('adjustContrast.results.title', 'Adjusted PDF'),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
forceStepNumbers: true,
});
};
export default AdjustContrast as ToolComponent;
+41 -50
View File
@@ -18,7 +18,6 @@ const Sign = (props: BaseToolProps) => {
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature();
const { consumeFiles, selectors } = useFileContext();
const { exportActions, getScrollState } = useViewer();
const { setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation();
// Track which signature mode was active for reactivation after save
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
@@ -39,11 +38,6 @@ const Sign = (props: BaseToolProps) => {
handleSignaturePlacement();
}, [handleSignaturePlacement]);
const handleDeactivateSignature = useCallback(() => {
activeModeRef.current = null;
deactivateDrawMode();
}, [deactivateDrawMode]);
const base = useBaseTool(
'sign',
useSignParameters,
@@ -51,18 +45,14 @@ const Sign = (props: BaseToolProps) => {
props
);
const hasOpenedViewer = useRef(false);
// Open viewer when files are selected (only once)
// Open viewer when files are selected
useEffect(() => {
if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
if (base.selectedFiles.length > 0) {
setWorkbench('viewer');
hasOpenedViewer.current = true;
}
}, [base.selectedFiles.length, setWorkbench]);
// Sync signature configuration with context
useEffect(() => {
setSignatureConfig(base.params.parameters);
@@ -71,10 +61,6 @@ const Sign = (props: BaseToolProps) => {
// Save signed files to the system - apply signatures using EmbedPDF and replace original
const handleSaveToSystem = useCallback(async () => {
try {
// Unregister unsaved changes checker to prevent warning during apply
unregisterUnsavedChangesChecker();
setHasUnsavedChanges(false);
// Get the original file
let originalFile = null;
if (base.selectedFiles.length > 0) {
@@ -95,63 +81,68 @@ const Sign = (props: BaseToolProps) => {
}
// Use the signature flattening utility
const flattenResult = await flattenSignatures({
const success = await flattenSignatures({
signatureApiRef,
getImageData,
exportActions,
selectors,
consumeFiles,
originalFile,
getScrollState
});
if (flattenResult) {
// Now consume the files - this triggers the viewer reload
await consumeFiles(
flattenResult.inputFileIds,
[flattenResult.outputStirlingFile],
[flattenResult.outputStub]
);
if (success) {
console.log('✓ Signature flattening completed successfully');
// Mark signatures as applied
setSignaturesApplied(true);
// Deactivate signature placement mode after everything completes
handleDeactivateSignature();
// Force refresh the viewer to show the flattened PDF
setTimeout(() => {
// Navigate away from viewer and back to force reload
setWorkbench('fileEditor');
setTimeout(() => {
setWorkbench('viewer');
// File has been consumed - viewer should reload automatically via key prop
// Reactivate the signature mode that was active before save
if (activeModeRef.current === 'draw') {
activateDrawMode();
} else if (activeModeRef.current === 'placement') {
handleSignaturePlacement();
}
}, 100);
}, 200);
} else {
console.error('Signature flattening failed');
}
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker]);
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode]);
const getSteps = () => {
const steps = [];
// Step 1: Signature Configuration - Only visible when file is loaded
if (base.selectedFiles.length > 0) {
steps.push({
title: t('sign.steps.configure', 'Configure Signature'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<SignSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
onActivateDrawMode={handleActivateDrawMode}
onActivateSignaturePlacement={handleActivateSignaturePlacement}
onDeactivateSignature={handleDeactivateSignature}
onUpdateDrawSettings={updateDrawSettings}
onUndo={undo}
onRedo={redo}
onSave={handleSaveToSystem}
/>
),
});
}
// Step 1: Signature Configuration - Always visible
steps.push({
title: t('sign.steps.configure', 'Configure Signature'),
isCollapsed: false,
onCollapsedClick: undefined,
content: (
<SignSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
onActivateDrawMode={handleActivateDrawMode}
onActivateSignaturePlacement={handleActivateSignaturePlacement}
onDeactivateSignature={deactivateDrawMode}
onUpdateDrawSettings={updateDrawSettings}
onUndo={undo}
onRedo={redo}
onSave={handleSaveToSystem}
/>
),
});
return steps;
};
-55
View File
@@ -1,55 +0,0 @@
/**
* Utility for dynamically loading external scripts
*/
interface ScriptLoadOptions {
src: string;
id?: string;
async?: boolean;
defer?: boolean;
onLoad?: () => void;
}
const loadedScripts = new Set<string>();
export function loadScript({ src, id, async = true, defer = false, onLoad }: ScriptLoadOptions): Promise<void> {
return new Promise((resolve, reject) => {
// Check if already loaded
const scriptId = id || src;
if (loadedScripts.has(scriptId)) {
resolve();
return;
}
// Check if script already exists in DOM
const existingScript = id ? document.getElementById(id) : document.querySelector(`script[src="${src}"]`);
if (existingScript) {
loadedScripts.add(scriptId);
resolve();
return;
}
// Create and append script
const script = document.createElement('script');
script.src = src;
if (id) script.id = id;
script.async = async;
script.defer = defer;
script.onload = () => {
loadedScripts.add(scriptId);
if (onLoad) onLoad();
resolve();
};
script.onerror = () => {
reject(new Error(`Failed to load script: ${src}`));
};
document.head.appendChild(script);
});
}
export function isScriptLoaded(idOrSrc: string): boolean {
return loadedScripts.has(idOrSrc);
}
+21 -31
View File
@@ -1,7 +1,7 @@
import { PDFDocument, rgb } from 'pdf-lib';
import { generateThumbnailWithMetadata } from './thumbnailUtils';
import { createProcessedFile, createChildStub } from '../contexts/file/fileActions';
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
import { createProcessedFile } from '../contexts/file/fileActions';
import { createNewStirlingFileStub, createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
interface MinimalFileContextSelectors {
@@ -17,18 +17,13 @@ interface SignatureFlatteningOptions {
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
selectors: MinimalFileContextSelectors;
consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise<FileId[]>;
originalFile?: StirlingFile;
getScrollState: () => { currentPage: number; totalPages: number };
}
export interface SignatureFlatteningResult {
inputFileIds: FileId[];
outputStirlingFile: StirlingFile;
outputStub: StirlingFileStub;
}
export async function flattenSignatures(options: SignatureFlatteningOptions): Promise<SignatureFlatteningResult | null> {
const { signatureApiRef, getImageData, exportActions, selectors, originalFile, getScrollState } = options;
export async function flattenSignatures(options: SignatureFlatteningOptions): Promise<boolean> {
const { signatureApiRef, getImageData, exportActions, selectors, consumeFiles, originalFile, getScrollState } = options;
try {
// Step 1: Extract all annotations from EmbedPDF before export
@@ -71,6 +66,8 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
}
console.log(`Total annotations found: ${allAnnotations.reduce((sum, page) => sum + page.annotations.length, 0)}`);
// Step 2: Delete ONLY session annotations from EmbedPDF before export (they'll be rendered manually)
// Leave old annotations alone - they will remain as annotations in the PDF
if (allAnnotations.length > 0 && signatureApiRef?.current) {
@@ -88,7 +85,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
// Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations)
if (!exportActions) {
console.error('No export actions available');
return null;
return false;
}
const pdfArrayBuffer = await exportActions.saveAsCopy();
@@ -114,7 +111,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
if (!currentFile) {
console.error('No file available to replace');
return null;
return false;
}
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
@@ -122,6 +119,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
// Step 4: Manually render extracted annotations onto the PDF using PDF-lib
if (allAnnotations.length > 0) {
try {
console.log('Manually rendering annotations onto PDF...');
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
// Try different loading options to handle problematic PDFs
@@ -152,6 +150,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
const pages = pdfDoc.getPages();
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
@@ -190,7 +189,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
try {
// Convert data URL to bytes
const base64Data = imageDataUrl.split(',')[1];
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
@@ -217,7 +215,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
console.error('Failed to render image annotation:', imageError);
}
} else if (annotation.content || annotation.text) {
console.warn('Rendering text annotation instead');
// Handle text annotations
page.drawText(annotation.content || annotation.text, {
x: pdfX,
@@ -290,30 +287,23 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
const record = selectors.getStirlingFileStub(currentFile.fileId);
if (!record) {
console.error('No file record found for:', currentFile.fileId);
return null;
return false;
}
// Create output stub and file as a child of the original (increments version)
const outputStub = createChildStub(
record,
{ toolId: 'sign', timestamp: Date.now() },
signedFile,
thumbnailResult.thumbnail,
processedFileMetadata
);
// Create output stub and file
const outputStub = createNewStirlingFileStub(signedFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(signedFile, outputStub.id);
// Return the flattened file data for consumption by caller
return {
inputFileIds,
outputStirlingFile,
outputStub
};
// Replace the original file with the signed version
await consumeFiles(inputFileIds, [outputStirlingFile], [outputStub]);
console.log('✓ Signature flattening completed successfully');
return true;
}
return null;
return false;
} catch (error) {
console.error('Error flattening signatures:', error);
return null;
return false;
}
}
+3 -20
View File
@@ -3,7 +3,6 @@ import { pdfWorkerManager } from '../services/pdfWorkerManager';
export interface ThumbnailWithMetadata {
thumbnail: string; // Always returns a thumbnail (placeholder if needed)
pageCount: number;
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
}
interface ColorScheme {
@@ -378,10 +377,8 @@ export async function generateThumbnailForFile(file: File): Promise<string> {
/**
* Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail
* @param applyRotation - If true, render thumbnail with PDF rotation applied (for static display).
* If false, render without rotation (for CSS-based rotation in PageEditor)
*/
export async function generateThumbnailWithMetadata(file: File, applyRotation: boolean = true): Promise<ThumbnailWithMetadata> {
export async function generateThumbnailWithMetadata(file: File): Promise<ThumbnailWithMetadata> {
// Non-PDF files have no page count
if (!file.type.startsWith('application/pdf')) {
const thumbnail = await generateThumbnailForFile(file);
@@ -402,13 +399,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
const pageCount = pdf.numPages;
const page = await pdf.getPage(1);
// If applyRotation is false, render without rotation (for CSS-based rotation)
// If applyRotation is true, let PDF.js apply rotation (for static display)
const viewport = applyRotation
? page.getViewport({ scale })
: page.getViewport({ scale, rotation: 0 });
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
@@ -422,16 +413,8 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
await page.render({ canvasContext: context, viewport, canvas }).promise;
const thumbnail = canvas.toDataURL();
// Read rotation for all pages
const pageRotations: number[] = [];
for (let i = 1; i <= pageCount; i++) {
const p = await pdf.getPage(i);
const rotation = p.rotate || 0;
pageRotations.push(rotation);
}
pdfWorkerManager.destroyDocument(pdf);
return { thumbnail, pageCount, pageRotations };
return { thumbnail, pageCount };
} catch (error) {
if (error instanceof Error && error.name === "PasswordException") {
+9 -10
View File
@@ -1,9 +1,8 @@
import { ToolId } from "src/types/toolId";
import { ToolRegistryEntry } from "../data/toolsTaxonomy";
import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch";
export interface RankedToolItem {
item: [ToolId, ToolRegistryEntry];
item: [string, ToolRegistryEntry];
matchedText?: string;
}
@@ -11,18 +10,18 @@ export function filterToolRegistryByQuery(
toolRegistry: Record<string, ToolRegistryEntry>,
query: string
): RankedToolItem[] {
const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][];
const entries = Object.entries(toolRegistry);
if (!query.trim()) {
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
}
const nq = normalizeForSearch(query);
const threshold = minScoreForQuery(query);
const exactName: Array<{ id: ToolId; tool: ToolRegistryEntry; pos: number }> = [];
const exactSyn: Array<{ id: ToolId; tool: ToolRegistryEntry; text: string; pos: number }> = [];
const fuzzyName: Array<{ id: ToolId; tool: ToolRegistryEntry; score: number; text: string }> = [];
const fuzzySyn: Array<{ id: ToolId; tool: ToolRegistryEntry; score: number; text: string }> = [];
const exactName: Array<{ id: string; tool: ToolRegistryEntry; pos: number }> = [];
const exactSyn: Array<{ id: string; tool: ToolRegistryEntry; text: string; pos: number }> = [];
const fuzzyName: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = [];
const fuzzySyn: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = [];
for (const [id, tool] of entries) {
const nameNorm = normalizeForSearch(tool.name || '');
@@ -79,7 +78,7 @@ export function filterToolRegistryByQuery(
const seen = new Set<string>();
const ordered: RankedToolItem[] = [];
const push = (id: ToolId, tool: ToolRegistryEntry, matchedText?: string) => {
const push = (id: string, tool: ToolRegistryEntry, matchedText?: string) => {
if (seen.has(id)) return;
seen.add(id);
ordered.push({ item: [id, tool], matchedText });
@@ -93,7 +92,7 @@ export function filterToolRegistryByQuery(
if (ordered.length > 0) return ordered;
// Fallback: return everything unchanged
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
}
-6
View File
@@ -1,6 +0,0 @@
{
"name": "Stirling-PDF",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}