mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32a0c04c4 | ||
|
|
b695e3900e | ||
|
|
2158ee4db6 |
@@ -258,12 +258,6 @@ public class AppConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Profile("default")
|
||||
public boolean googleDriveEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "license")
|
||||
@Profile("default")
|
||||
public String licenseType() {
|
||||
|
||||
@@ -530,7 +530,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -549,26 +548,6 @@ 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
|
||||
|
||||
-16
@@ -109,22 +109,6 @@ 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();
|
||||
|
||||
-5
@@ -98,11 +98,6 @@ 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,11 +76,6 @@ premium:
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
googleDrive:
|
||||
enabled: false
|
||||
clientId: ''
|
||||
apiKey: ''
|
||||
appId: ''
|
||||
enterpriseFeatures:
|
||||
audit:
|
||||
enabled: true # Enable audit logging
|
||||
|
||||
@@ -422,10 +422,6 @@
|
||||
<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>
|
||||
@@ -443,16 +439,4 @@
|
||||
</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>
|
||||
|
||||
-14
@@ -12,7 +12,6 @@ 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)
|
||||
@@ -55,19 +54,6 @@ 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) {
|
||||
|
||||
+239
-247
@@ -8,14 +8,15 @@ Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This
|
||||
|
||||
Stirling-PDF is built using:
|
||||
|
||||
- Spring Boot + Thymeleaf
|
||||
- PDFBox
|
||||
- LibreOffice
|
||||
- qpdf
|
||||
- HTML, CSS, JavaScript
|
||||
- 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)
|
||||
- Docker
|
||||
- PDF.js
|
||||
- PDF-LIB.js
|
||||
- Lombok
|
||||
|
||||
## 3. Development Environment Setup
|
||||
@@ -24,8 +25,9 @@ Stirling-PDF is built using:
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 17 or later
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- Node.js 18+ and npm (for frontend development)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
@@ -38,8 +40,8 @@ Stirling-PDF is built using:
|
||||
|
||||
2. Install Docker and JDK17 if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
3. Install a recommended IDE:
|
||||
- **VSCode** (recommended for frontend)
|
||||
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:
|
||||
@@ -49,13 +51,15 @@ 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 DISABLE_ADDITIONAL_FEATURES=false 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 DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
|
||||
|
||||
## 4. Project Structure
|
||||
|
||||
@@ -68,10 +72,21 @@ 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/ # Source code
|
||||
├── src/ # Backend source code
|
||||
│ ├── main/
|
||||
│ │ ├── java/
|
||||
│ │ │ └── stirling/
|
||||
@@ -79,16 +94,14 @@ Stirling-PDF/
|
||||
│ │ │ └── SPDF/
|
||||
│ │ │ ├── config/
|
||||
│ │ │ ├── controller/
|
||||
│ │ │ │ ├── api/ # REST API endpoints
|
||||
│ │ │ │ └── web/ # Web controllers
|
||||
│ │ │ ├── model/
|
||||
│ │ │ ├── repository/
|
||||
│ │ │ ├── service/
|
||||
│ │ │ └── utils/
|
||||
│ │ └── resources/
|
||||
│ │ ├── static/
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── pdfjs/
|
||||
│ │ └── templates/
|
||||
│ │ └── static/ # Legacy static assets
|
||||
│ └── test/
|
||||
│ └── java/
|
||||
│ └── stirling/
|
||||
@@ -141,7 +154,7 @@ services:
|
||||
- ./stirling/latest/config:/configs:rw
|
||||
- ./stirling/latest/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
@@ -170,7 +183,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
1. Set the security environment variable:
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
|
||||
export DOCKER_ENABLE_SECURITY=true # or false to disable login and security features for builds
|
||||
```
|
||||
|
||||
2. Build the project with Gradle:
|
||||
@@ -196,7 +209,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
For the fat version (with login and security features enabled):
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=false
|
||||
export DOCKER_ENABLE_SECURITY=true
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
|
||||
```
|
||||
|
||||
@@ -224,38 +237,50 @@ 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 (Java and UI Components)
|
||||
### Local Testing (Frontend and Backend)
|
||||
|
||||
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:
|
||||
For quick iterations and development, you can run the frontend and backend separately:
|
||||
|
||||
- Java backend logic
|
||||
- RESTful API endpoints
|
||||
- JavaScript functionality
|
||||
- User interface components and styling
|
||||
- Thymeleaf templates
|
||||
#### Backend Development
|
||||
|
||||
To run Stirling-PDF locally:
|
||||
|
||||
1. Compile and run the project using built-in IDE methods or by running:
|
||||
1. Run the backend:
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
2. Access the application at `http://localhost:8080` in your web browser.
|
||||
2. The backend API will be available at `http://localhost:8080`
|
||||
|
||||
3. Manually test the features you're working on through the UI.
|
||||
3. API documentation is available at `http://localhost:8080/swagger-ui/index.html`
|
||||
|
||||
4. For API changes, use tools like Postman or curl to test endpoints directly.
|
||||
#### 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`
|
||||
|
||||
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
|
||||
@@ -307,112 +332,170 @@ 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. Language Translations
|
||||
## 10. Frontend Development (V2)
|
||||
|
||||
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
|
||||
### Architecture Overview
|
||||
|
||||
```bash
|
||||
/scripts/replace_translation_line.sh
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
This script helps you make consistent replacements across language files.
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
|
||||
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>
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
|
||||
They can be more complex, such as:
|
||||
|
||||
```html
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
**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
|
||||
|
||||
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
|
||||
### Adding a New Tool
|
||||
|
||||
```java
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("exampleData", exampleData);
|
||||
return "new-feature";
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new languages.
|
||||
|
||||
```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>
|
||||
```
|
||||
## 11. Backend Development
|
||||
|
||||
This would generate n entries of tr for each person in exampleData
|
||||
|
||||
### Adding a New Feature to the Backend (API)
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. **Create a New Controller:**
|
||||
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/api` directory.
|
||||
- Create a new Java class in the `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(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")`.
|
||||
- Ensure to add API documentation annotations like `@Tag` and `@Operation`.
|
||||
|
||||
```java
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/new-feature")
|
||||
@RequestMapping("/api/v1/pdf")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
public class NewFeatureController {
|
||||
|
||||
@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
|
||||
@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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
2. **Define the Service Layer:** (Optional but recommended)
|
||||
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
|
||||
- Implement the business logic for the new feature.
|
||||
|
||||
```java
|
||||
@@ -423,167 +506,76 @@ This would generate n entries of tr for each person in exampleData
|
||||
@Service
|
||||
public class NewFeatureService {
|
||||
|
||||
public String getNewFeatureData() {
|
||||
public byte[] processFile(MultipartFile file, String param1) {
|
||||
// Implement business logic here
|
||||
return "New Feature Data";
|
||||
return processedBytes;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
3. **Integrate the Service with the Controller:**
|
||||
|
||||
```java
|
||||
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 {
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/pdf")
|
||||
public class NewFeatureController {
|
||||
|
||||
@Autowired
|
||||
private NewFeatureService newFeatureService;
|
||||
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
|
||||
return "new-feature";
|
||||
@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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
### Multi-File Endpoints
|
||||
|
||||
```html
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
|
||||
</li>
|
||||
```
|
||||
For tools that process multiple files in one request:
|
||||
|
||||
## Adding New Translations to Existing Language Files in Stirling-PDF
|
||||
```java
|
||||
@PostMapping("/merge")
|
||||
public ResponseEntity<byte[]> mergePdfs(
|
||||
@RequestPart("fileInput") MultipartFile[] 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:
|
||||
// Process all files together
|
||||
byte[] merged = mergeService.mergeFiles(files);
|
||||
|
||||
### 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
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=merged.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(merged);
|
||||
}
|
||||
```
|
||||
|
||||
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
|
||||
## 12. Best Practices
|
||||
|
||||
### 3. Use Translations in Thymeleaf Templates
|
||||
### 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
|
||||
|
||||
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
|
||||
### 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
|
||||
|
||||
```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.
|
||||
### 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`
|
||||
|
||||
+149
-27
@@ -8,36 +8,66 @@
|
||||
|
||||
Fork Stirling-PDF and create a new branch out of `main`.
|
||||
|
||||
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
|
||||
## Add Language to i18n Configuration
|
||||
|
||||
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
|
||||
Edit the file: [frontend/src/i18n.ts](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/src/i18n.ts)
|
||||
|
||||
Add your language to the `supportedLanguages` object. For example, to add Polish:
|
||||
|
||||
For example, to add Polish, you would add:
|
||||
|
||||
```html
|
||||
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
|
||||
```typescript
|
||||
export const supportedLanguages = {
|
||||
'en': 'English',
|
||||
'en-GB': 'English (UK)',
|
||||
// ... other languages ...
|
||||
'pl-PL': 'Polski', // Add your language here
|
||||
};
|
||||
```
|
||||
|
||||
The `data-bs-language-code` is the code used to reference the file in the next step.
|
||||
If your language uses right-to-left (RTL) text direction, also add it to the `rtlLanguages` array:
|
||||
|
||||
### Add Language Property File
|
||||
```typescript
|
||||
export const rtlLanguages = ['ar-AR', 'fa-IR', 'pl-PL']; // Add if RTL
|
||||
```
|
||||
|
||||
Start by copying the existing English property file:
|
||||
## Create Translation Directory
|
||||
|
||||
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
|
||||
Create a new directory for your language in `frontend/public/locales/`. For Polish, this would be:
|
||||
|
||||
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`.
|
||||
```bash
|
||||
mkdir -p frontend/public/locales/pl-PL
|
||||
```
|
||||
|
||||
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
|
||||
## Add Translation File
|
||||
|
||||
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).
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Handling Untranslatable Strings
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```toml
|
||||
[pl_PL]
|
||||
@@ -50,27 +80,119 @@ ignore = [
|
||||
## Add New Translation Tags
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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.
|
||||
> 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.
|
||||
|
||||
- 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`).
|
||||
- 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`).
|
||||
|
||||
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.
|
||||
|
||||
### Use this code to perform a local check
|
||||
## Testing Your Translation
|
||||
|
||||
#### Windows command
|
||||
### Start the development server
|
||||
|
||||
```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
|
||||
1. Start the frontend development server:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
2. The language selector should now include your new language
|
||||
|
||||
#### Linux command
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
# 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 "" --check-file 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
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
+8
-1
@@ -50,7 +50,14 @@ 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
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ 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,6 +44,9 @@ 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:
|
||||
|
||||
@@ -46,6 +46,9 @@ 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:
|
||||
|
||||
Generated
+81
@@ -54,6 +54,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -66,6 +67,10 @@
|
||||
"@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",
|
||||
@@ -2009,6 +2014,28 @@
|
||||
"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",
|
||||
@@ -3595,6 +3622,54 @@
|
||||
"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",
|
||||
@@ -9918,6 +9993,12 @@
|
||||
"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",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -105,6 +106,10 @@
|
||||
"@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",
|
||||
|
||||
@@ -1819,8 +1819,16 @@
|
||||
"placeholder": "Enter your full name"
|
||||
},
|
||||
"instructions": {
|
||||
"title": "How to add signature"
|
||||
"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."
|
||||
},
|
||||
"mode": {
|
||||
"move": "Move Signature",
|
||||
"place": "Place Signature"
|
||||
},
|
||||
"updateAndPlace": "Update and Place",
|
||||
"activate": "Activate Signature Placement",
|
||||
"deactivate": "Stop Placing Signatures",
|
||||
"results": {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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;
|
||||
@@ -84,6 +86,29 @@ 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,7 +1,8 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
||||
import { ColorSwatchButton } from './ColorPicker';
|
||||
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
selectedColor: string;
|
||||
@@ -11,6 +12,7 @@ interface DrawingCanvasProps {
|
||||
onPenSizeChange: (size: number) => void;
|
||||
onPenSizeInputChange: (input: string) => void;
|
||||
onSignatureDataChange: (data: string | null) => void;
|
||||
onDrawingComplete?: () => void;
|
||||
disabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -27,411 +29,253 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
onPenSizeChange,
|
||||
onPenSizeInputChange,
|
||||
onSignatureDataChange,
|
||||
onDrawingComplete,
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
modalWidth = 800,
|
||||
modalHeight = 400,
|
||||
additionalButtons
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const visibleModalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [isModalDrawing, setIsModalDrawing] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
// 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);
|
||||
padRef.current = new SignaturePad(canvas, {
|
||||
penColor: selectedColor,
|
||||
minWidth: penSize * 0.5,
|
||||
maxWidth: penSize * 2.5,
|
||||
throttle: 10,
|
||||
minDistance: 5,
|
||||
velocityFilterWeight: 0.7,
|
||||
});
|
||||
}
|
||||
}, [disabled, selectedColor, penSize]);
|
||||
};
|
||||
|
||||
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing || !canvasRef.current || disabled) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const scaleX = canvasRef.current.width / rect.width;
|
||||
const scaleY = canvasRef.current.height / rect.height;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
const openModal = () => {
|
||||
// Clear pad ref so it reinitializes
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
}, [isDrawing, disabled]);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const stopDrawing = useCallback(() => {
|
||||
if (!isDrawing || disabled) return;
|
||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas.toDataURL('image/png');
|
||||
|
||||
setIsDrawing(false);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
// Save canvas as signature data
|
||||
if (canvasRef.current) {
|
||||
const dataURL = canvasRef.current.toDataURL('image/png');
|
||||
onSignatureDataChange(dataURL);
|
||||
}
|
||||
}, [isDrawing, disabled, onSignatureDataChange]);
|
||||
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isModalDrawing]);
|
||||
|
||||
// Clear canvas functions
|
||||
const clearCanvas = useCallback(() => {
|
||||
if (!canvasRef.current || disabled) return;
|
||||
const trimWidth = maxX - minX + 1;
|
||||
const trimHeight = maxY - minY + 1;
|
||||
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (visibleModalCanvasRef.current) {
|
||||
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||
if (visibleCtx) {
|
||||
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
return trimmedCanvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
const closeModal = () => {
|
||||
if (padRef.current && !padRef.current.isEmpty()) {
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
onSignatureDataChange(trimmedPng);
|
||||
|
||||
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) {
|
||||
// Update preview canvas with proper aspect ratio
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||
ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = dataURL;
|
||||
}
|
||||
}
|
||||
img.src = trimmedPng;
|
||||
|
||||
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 (onDrawingComplete) {
|
||||
onDrawingComplete();
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}, [selectedColor, penSize]);
|
||||
}
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
// Initialize canvas settings whenever color or pen size changes
|
||||
React.useEffect(() => {
|
||||
const updateCanvas = (canvas: HTMLCanvasElement | null) => {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const clear = () => {
|
||||
if (padRef.current) {
|
||||
padRef.current.clear();
|
||||
}
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.strokeStyle = selectedColor;
|
||||
ctx.lineWidth = penSize;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
};
|
||||
}
|
||||
onSignatureDataChange(null);
|
||||
};
|
||||
|
||||
updateCanvas(canvasRef.current);
|
||||
updateCanvas(modalCanvasRef.current);
|
||||
updateCanvas(visibleModalCanvasRef.current);
|
||||
}, [selectedColor, penSize]);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<Group gap="lg">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" ta="center">Color</Text>
|
||||
<Group justify="center">
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
onValueChange={onPenSizeChange}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
disabled={disabled}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ paddingTop: '24px' }}>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={openModal}
|
||||
disabled={disabled}
|
||||
>
|
||||
Expand
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'crosshair',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
{additionalButtons}
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={clearCanvas}
|
||||
disabled={disabled}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Click to open drawing canvas
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Hidden canvas for modal synchronization */}
|
||||
<canvas
|
||||
ref={modalCanvasRef}
|
||||
width={modalWidth}
|
||||
height={modalHeight}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Modal for larger signature canvas */}
|
||||
<Modal
|
||||
opened={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title="Draw Your Signature"
|
||||
size="xl"
|
||||
centered
|
||||
>
|
||||
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
|
||||
<Stack gap="md">
|
||||
{/* Color and Pen Size picker */}
|
||||
<Paper withBorder p="sm">
|
||||
<Group gap="lg" align="flex-end">
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
onValueChange={onPenSizeChange}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
</Paper>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={clearModalCanvas}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
Clear Canvas
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveModalSignature}
|
||||
>
|
||||
Save Signature
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Button onClick={closeModal}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</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 a PNG or JPG image of your signature')}
|
||||
{hint || t('sign.image.hint', 'Upload an image of your signature')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
text: string;
|
||||
@@ -9,6 +10,8 @@ interface TextInputWithFontProps {
|
||||
onFontSizeChange: (size: number) => void;
|
||||
fontFamily: string;
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
@@ -21,6 +24,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
onFontSizeChange,
|
||||
fontFamily,
|
||||
onFontFamilyChange,
|
||||
textColor = '#000000',
|
||||
onTextColorChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder
|
||||
@@ -28,6 +33,7 @@ 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(() => {
|
||||
@@ -42,7 +48,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
];
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48'];
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -66,61 +72,101 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{/* Font Size */}
|
||||
<Combobox
|
||||
onOptionSubmit={(optionValue) => {
|
||||
setFontSizeInput(optionValue);
|
||||
const size = parseInt(optionValue);
|
||||
if (!isNaN(size)) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
fontSizeCombobox.closeDropdown();
|
||||
}}
|
||||
store={fontSizeCombobox}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-72)"
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setFontSizeInput(value);
|
||||
{/* 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);
|
||||
|
||||
// Parse and validate the typed value in real-time
|
||||
const size = parseInt(value);
|
||||
if (!isNaN(size) && size >= 8 && size <= 72) {
|
||||
onFontSizeChange(size);
|
||||
// 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'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
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>
|
||||
{/* Color Picker Modal */}
|
||||
{onTextColorChange && (
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={textColor}
|
||||
onColorChange={onTextColorChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -13,8 +14,20 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = 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',
|
||||
@@ -67,15 +80,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={buttonProps.variant('drive')}
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<CloudIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={() => onSourceChange('drive')}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled
|
||||
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||
styles={buttonProps.getStyles('drive')}
|
||||
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}
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
@@ -51,7 +51,6 @@ const Overview: React.FC = () => {
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -25,13 +26,17 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
const { signatureApiRef } = useSignature();
|
||||
const { signatureApiRef, isPlacementMode } = 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) {
|
||||
@@ -39,6 +44,11 @@ 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 */}
|
||||
@@ -50,7 +60,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode}
|
||||
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Button, Text, Alert, Tabs } from '@mantine/core';
|
||||
import { Stack, Button, Text, Alert, Tabs, SegmentedControl } 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";
|
||||
@@ -35,12 +36,14 @@ 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);
|
||||
@@ -96,20 +99,29 @@ const SignSettings = ({
|
||||
}
|
||||
}, [parameters.signatureType]);
|
||||
|
||||
// Handle text signature activation
|
||||
// Handle text signature activation (including fontSize and fontFamily changes)
|
||||
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, onActivateSignaturePlacement, 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]);
|
||||
|
||||
// Handle signature data updates
|
||||
useEffect(() => {
|
||||
@@ -130,12 +142,23 @@ 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 (
|
||||
@@ -170,7 +193,7 @@ const SignSettings = ({
|
||||
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
|
||||
disabled={disabled}
|
||||
showPlaceButton={false}
|
||||
placeButtonText="Update and Place"
|
||||
placeButtonText={t('sign.updateAndPlace', 'Update and Place')}
|
||||
/>
|
||||
|
||||
{/* Signature Creation based on type */}
|
||||
@@ -183,6 +206,11 @@ const SignSettings = ({
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={handleCanvasSignatureChange}
|
||||
onDrawingComplete={() => {
|
||||
if (onActivateSignaturePlacement) {
|
||||
onActivateSignaturePlacement();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
additionalButtons={
|
||||
<Button
|
||||
@@ -195,7 +223,7 @@ const SignSettings = ({
|
||||
variant="filled"
|
||||
disabled={disabled || !canvasSignatureData}
|
||||
>
|
||||
Update and Place
|
||||
{t('sign.updateAndPlace', 'Update and Place')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -216,17 +244,43 @@ 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' && '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.'}
|
||||
{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.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useNavigationGuard, useNavigationState } from '../../contexts/Navigatio
|
||||
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;
|
||||
@@ -263,6 +264,7 @@ 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}
|
||||
|
||||
@@ -314,7 +314,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,34 +1,30 @@
|
||||
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
|
||||
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
|
||||
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
|
||||
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 - when in signature placement mode or viewer annotation mode
|
||||
// Enable keyboard deletion of selected annotations
|
||||
useEffect(() => {
|
||||
if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return;
|
||||
// Always enable delete key when we have annotation API and are in sign mode
|
||||
if (!annotationApi || (isPlacementMode === undefined)) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||
|
||||
@@ -67,7 +63,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [annotationApi, storeImageData, isPlacementMode, isAnnotationMode]);
|
||||
}, [annotationApi, storeImageData, isPlacementMode]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
|
||||
@@ -100,34 +96,6 @@ 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;
|
||||
|
||||
@@ -152,45 +120,31 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
|
||||
try {
|
||||
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||
// Try different tool names for text annotations
|
||||
const textToolNames = ['freetext', 'text', 'textbox', 'annotation-text'];
|
||||
let activatedTool = null;
|
||||
|
||||
for (const toolName of textToolNames) {
|
||||
annotationApi.setActiveTool(toolName);
|
||||
const tool = annotationApi.getActiveTool();
|
||||
|
||||
if (tool && tool.id === toolName) {
|
||||
activatedTool = tool;
|
||||
annotationApi.setToolDefaults(toolName, {
|
||||
contents: signatureConfig.signerName,
|
||||
fontSize: signatureConfig.fontSize || 16,
|
||||
fontFamily: signatureConfig.fontFamily === 'Times-Roman' ? PdfStandardFont.Times_Roman :
|
||||
signatureConfig.fontFamily === 'Courier' ? PdfStandardFont.Courier :
|
||||
PdfStandardFont.Helvetica,
|
||||
fontColor: '#000000',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Skip native text tools - always use stamp for consistent sizing
|
||||
const activatedTool = null;
|
||||
|
||||
if (!activatedTool) {
|
||||
// Fallback: create a simple text image as stamp
|
||||
// Create text image as stamp with actual pixel size matching desired display size
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const fontSize = signatureConfig.fontSize || 16;
|
||||
const baseFontSize = signatureConfig.fontSize || 16;
|
||||
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||
const textColor = signatureConfig.textColor || '#000000';
|
||||
|
||||
canvas.width = Math.max(200, signatureConfig.signerName.length * fontSize * 0.6);
|
||||
canvas.height = fontSize + 20;
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
// 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}`;
|
||||
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') {
|
||||
@@ -205,6 +159,7 @@ 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,
|
||||
@@ -267,84 +222,6 @@ 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');
|
||||
|
||||
@@ -39,6 +39,7 @@ interface FileManagerContextValue {
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
onGoogleDriveSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: StirlingFileStub[];
|
||||
@@ -546,6 +547,19 @@ 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
|
||||
@@ -623,6 +637,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onUnzipFile: handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
onGoogleDriveSelect: handleGoogleDriveSelect,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
@@ -656,6 +671,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
handleAddToRecents,
|
||||
handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
handleGoogleDriveSelect,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
|
||||
@@ -51,8 +51,9 @@ function processFileSwap(
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
// Clear selections that reference removed files and add new files to selection
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
|
||||
const newSelectedFileIds = [...validSelectedFileIds, ...addedIds];
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -62,7 +63,7 @@ function processFileSwap(
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds
|
||||
selectedFileIds: newSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SignParameters {
|
||||
signerName?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PARAMETERS: SignParameters = {
|
||||
@@ -26,6 +27,7 @@ export const DEFAULT_PARAMETERS: SignParameters = {
|
||||
signerName: '',
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 16,
|
||||
textColor: '#000000',
|
||||
};
|
||||
|
||||
const validateSignParameters = (parameters: SignParameters): boolean => {
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface AppConfig {
|
||||
runningProOrHigher?: boolean;
|
||||
runningEE?: boolean;
|
||||
license?: string;
|
||||
GoogleDriveEnabled?: boolean;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
error?: string;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
+50
-41
@@ -18,6 +18,7 @@ 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);
|
||||
@@ -38,6 +39,11 @@ const Sign = (props: BaseToolProps) => {
|
||||
handleSignaturePlacement();
|
||||
}, [handleSignaturePlacement]);
|
||||
|
||||
const handleDeactivateSignature = useCallback(() => {
|
||||
activeModeRef.current = null;
|
||||
deactivateDrawMode();
|
||||
}, [deactivateDrawMode]);
|
||||
|
||||
const base = useBaseTool(
|
||||
'sign',
|
||||
useSignParameters,
|
||||
@@ -45,14 +51,18 @@ const Sign = (props: BaseToolProps) => {
|
||||
props
|
||||
);
|
||||
|
||||
// Open viewer when files are selected
|
||||
const hasOpenedViewer = useRef(false);
|
||||
|
||||
// Open viewer when files are selected (only once)
|
||||
useEffect(() => {
|
||||
if (base.selectedFiles.length > 0) {
|
||||
if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
|
||||
setWorkbench('viewer');
|
||||
hasOpenedViewer.current = true;
|
||||
}
|
||||
}, [base.selectedFiles.length, setWorkbench]);
|
||||
|
||||
|
||||
|
||||
// Sync signature configuration with context
|
||||
useEffect(() => {
|
||||
setSignatureConfig(base.params.parameters);
|
||||
@@ -61,6 +71,10 @@ 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) {
|
||||
@@ -81,68 +95,63 @@ const Sign = (props: BaseToolProps) => {
|
||||
}
|
||||
|
||||
// Use the signature flattening utility
|
||||
const success = await flattenSignatures({
|
||||
const flattenResult = await flattenSignatures({
|
||||
signatureApiRef,
|
||||
getImageData,
|
||||
exportActions,
|
||||
selectors,
|
||||
consumeFiles,
|
||||
originalFile,
|
||||
getScrollState
|
||||
});
|
||||
|
||||
if (success) {
|
||||
console.log('✓ Signature flattening completed successfully');
|
||||
if (flattenResult) {
|
||||
// Now consume the files - this triggers the viewer reload
|
||||
await consumeFiles(
|
||||
flattenResult.inputFileIds,
|
||||
[flattenResult.outputStirlingFile],
|
||||
[flattenResult.outputStub]
|
||||
);
|
||||
|
||||
// Mark signatures as applied
|
||||
setSignaturesApplied(true);
|
||||
|
||||
// Force refresh the viewer to show the flattened PDF
|
||||
setTimeout(() => {
|
||||
// Navigate away from viewer and back to force reload
|
||||
setWorkbench('fileEditor');
|
||||
setTimeout(() => {
|
||||
setWorkbench('viewer');
|
||||
// Deactivate signature placement mode after everything completes
|
||||
handleDeactivateSignature();
|
||||
|
||||
// Reactivate the signature mode that was active before save
|
||||
if (activeModeRef.current === 'draw') {
|
||||
activateDrawMode();
|
||||
} else if (activeModeRef.current === 'placement') {
|
||||
handleSignaturePlacement();
|
||||
}
|
||||
}, 100);
|
||||
}, 200);
|
||||
// File has been consumed - viewer should reload automatically via key prop
|
||||
} else {
|
||||
console.error('Signature flattening failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving signed document:', error);
|
||||
}
|
||||
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode]);
|
||||
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker]);
|
||||
|
||||
const getSteps = () => {
|
||||
const steps = [];
|
||||
|
||||
// Step 1: Signature Configuration - Always visible
|
||||
steps.push({
|
||||
title: t('sign.steps.configure', 'Configure Signature'),
|
||||
isCollapsed: false,
|
||||
onCollapsedClick: undefined,
|
||||
content: (
|
||||
<SignSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
onActivateDrawMode={handleActivateDrawMode}
|
||||
onActivateSignaturePlacement={handleActivateSignaturePlacement}
|
||||
onDeactivateSignature={deactivateDrawMode}
|
||||
onUpdateDrawSettings={updateDrawSettings}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onSave={handleSaveToSystem}
|
||||
/>
|
||||
),
|
||||
});
|
||||
// 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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PDFDocument, rgb } from 'pdf-lib';
|
||||
import { generateThumbnailWithMetadata } from './thumbnailUtils';
|
||||
import { createProcessedFile } from '../contexts/file/fileActions';
|
||||
import { createNewStirlingFileStub, createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
|
||||
import { createProcessedFile, createChildStub } from '../contexts/file/fileActions';
|
||||
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
|
||||
import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
|
||||
|
||||
interface MinimalFileContextSelectors {
|
||||
@@ -17,13 +17,18 @@ 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 async function flattenSignatures(options: SignatureFlatteningOptions): Promise<boolean> {
|
||||
const { signatureApiRef, getImageData, exportActions, selectors, consumeFiles, originalFile, getScrollState } = options;
|
||||
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;
|
||||
|
||||
try {
|
||||
// Step 1: Extract all annotations from EmbedPDF before export
|
||||
@@ -66,8 +71,6 @@ 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) {
|
||||
@@ -85,7 +88,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 false;
|
||||
return null;
|
||||
}
|
||||
const pdfArrayBuffer = await exportActions.saveAsCopy();
|
||||
|
||||
@@ -111,7 +114,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
|
||||
if (!currentFile) {
|
||||
console.error('No file available to replace');
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
|
||||
@@ -119,7 +122,6 @@ 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
|
||||
@@ -150,7 +152,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
|
||||
for (const pageData of allAnnotations) {
|
||||
const { pageIndex, annotations } = pageData;
|
||||
|
||||
@@ -189,6 +190,7 @@ 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));
|
||||
@@ -215,6 +217,7 @@ 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,
|
||||
@@ -287,23 +290,30 @@ 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 false;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create output stub and file
|
||||
const outputStub = createNewStirlingFileStub(signedFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||
// 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
|
||||
);
|
||||
const outputStirlingFile = createStirlingFile(signedFile, outputStub.id);
|
||||
|
||||
// Replace the original file with the signed version
|
||||
await consumeFiles(inputFileIds, [outputStirlingFile], [outputStub]);
|
||||
|
||||
console.log('✓ Signature flattening completed successfully');
|
||||
return true;
|
||||
// Return the flattened file data for consumption by caller
|
||||
return {
|
||||
inputFileIds,
|
||||
outputStirlingFile,
|
||||
outputStub
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error flattening signatures:', error);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Stirling-PDF",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user