mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32a0c04c4 | ||
|
|
b695e3900e | ||
|
|
2158ee4db6 | ||
|
|
3090a85726 | ||
|
|
d714a1617f | ||
|
|
2a29bda34f | ||
|
|
ab6edd3196 | ||
|
|
be7e79be55 | ||
|
|
c9e1b8eec5 | ||
|
|
eba93a3b6c | ||
|
|
03e81a0f16 | ||
|
|
f9ac1bd62e | ||
|
|
8aa6aff53a | ||
|
|
458bb641b5 | ||
|
|
247f82b5a7 | ||
|
|
06b4c147bd | ||
|
|
25154e4dbe | ||
|
|
989eea9e24 | ||
|
|
510e1c38eb | ||
|
|
ec05c5c049 | ||
|
|
d86a13cc89 | ||
|
|
85dedf4b28 | ||
|
|
0fa53185f2 |
@@ -192,6 +192,11 @@ return useToolOperation({
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
|
||||
@@ -115,45 +115,45 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1916,6 +1916,7 @@ fileManager.storageError=Storage error occurred
|
||||
fileManager.storageLow=Storage is running low. Consider removing old files.
|
||||
fileManager.uploadError=Failed to upload some files.
|
||||
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
|
||||
fileManager.loadingFiles=Loading files...
|
||||
|
||||
# Page Editor
|
||||
pageEditor.title=Page Editor
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import globals from "globals";
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const srcGlobs = [
|
||||
'src/**/*.{js,mjs,jsx,ts,tsx}',
|
||||
];
|
||||
const nodeGlobs = [
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'*.config.{js,ts,mjs}',
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
@@ -15,7 +24,6 @@ export default defineConfig(
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"no-undef": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
@@ -38,5 +46,23 @@ export default defineConfig(
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
// Config for browser scripts
|
||||
{
|
||||
files: srcGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
}
|
||||
}
|
||||
},
|
||||
// Config for node scripts
|
||||
{
|
||||
files: nodeGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+98
-4
@@ -41,6 +41,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
@@ -53,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"
|
||||
},
|
||||
@@ -65,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",
|
||||
@@ -1760,6 +1766,19 @@
|
||||
"mlly": "^1.7.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/utils/node_modules/globals": {
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -1995,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",
|
||||
@@ -3581,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",
|
||||
@@ -6415,10 +6504,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||
"dev": true,
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
|
||||
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9905,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",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
@@ -49,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"
|
||||
},
|
||||
@@ -104,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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
|
||||
"unsavedChanges": "You have unsaved changes to your PDF.",
|
||||
"areYouSure": "Are you sure you want to leave?",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"keepWorking": "Keep Working",
|
||||
"discardChanges": "Discard Changes",
|
||||
"applyAndContinue": "Apply & Continue",
|
||||
"discardChanges": "Discard & Leave",
|
||||
"applyAndContinue": "Save & Leave",
|
||||
"exportAndContinue": "Export & Continue",
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
@@ -65,6 +66,8 @@
|
||||
"save": "Save",
|
||||
"saveToBrowser": "Save to Browser",
|
||||
"download": "Download",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
"undo": "Undo",
|
||||
"moreOptions": "More Options",
|
||||
@@ -255,6 +258,33 @@
|
||||
"cacheInputs": {
|
||||
"name": "Save form inputs",
|
||||
"help": "Enable to store previously used inputs for future runs"
|
||||
},
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "Configure general application preferences.",
|
||||
"autoUnzip": "Auto-unzip API responses",
|
||||
"autoUnzipDescription": "Automatically extract files from ZIP responses",
|
||||
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
|
||||
"autoUnzipFileLimit": "Auto-unzip file limit",
|
||||
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
|
||||
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
|
||||
"errorModifier": {
|
||||
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
|
||||
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
|
||||
},
|
||||
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||
"none": "Not assigned",
|
||||
"customBadge": "Custom",
|
||||
"defaultLabel": "Default: {{shortcut}}",
|
||||
"capturing": "Press keys… (Esc to cancel)",
|
||||
"change": "Change shortcut",
|
||||
"reset": "Reset",
|
||||
"shortcut": "Shortcut",
|
||||
"noShortcut": "No shortcut set"
|
||||
}
|
||||
},
|
||||
"changeCreds": {
|
||||
@@ -531,7 +561,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colours/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@@ -868,6 +898,7 @@
|
||||
"rotate": {
|
||||
"title": "Rotate PDF",
|
||||
"submit": "Apply Rotation",
|
||||
"selectRotation": "Select Rotation Angle (Clockwise)",
|
||||
"error": {
|
||||
"failed": "An error occurred while rotating the PDF."
|
||||
},
|
||||
@@ -1788,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": {
|
||||
@@ -2774,8 +2813,9 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
@@ -3062,7 +3102,12 @@
|
||||
"panMode": "Pan Mode",
|
||||
"rotateLeft": "Rotate Left",
|
||||
"rotateRight": "Rotate Right",
|
||||
"toggleSidebar": "Toggle Sidebar"
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"exportSelected": "Export Selected Pages",
|
||||
"toggleAnnotations": "Toggle Annotations Visibility",
|
||||
"annotationMode": "Toggle Annotation Mode",
|
||||
"draw": "Draw",
|
||||
"save": "Save"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search PDF",
|
||||
@@ -3104,6 +3149,7 @@
|
||||
"automate": "Automate",
|
||||
"files": "Files",
|
||||
"activity": "Activity",
|
||||
"account": "Account",
|
||||
"config": "Config",
|
||||
"allTools": "All Tools"
|
||||
},
|
||||
@@ -3131,6 +3177,9 @@
|
||||
"addFiles": "Add Files",
|
||||
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
|
||||
},
|
||||
"fileEditor": {
|
||||
"addFiles": "Add Files"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "Upload PDF Files",
|
||||
"subtitle": "Add files to your storage for easy access across tools",
|
||||
@@ -3159,6 +3208,7 @@
|
||||
"lastModified": "Last Modified",
|
||||
"toolChain": "Tools Applied",
|
||||
"restore": "Restore",
|
||||
"unzip": "Unzip",
|
||||
"searchFiles": "Search files...",
|
||||
"recent": "Recent",
|
||||
"localFiles": "Local Files",
|
||||
@@ -3166,7 +3216,6 @@
|
||||
"googleDriveShort": "Drive",
|
||||
"myFiles": "My Files",
|
||||
"noRecentFiles": "No recent files found",
|
||||
"dropFilesHint": "Drop files here to upload",
|
||||
"googleDriveNotAvailable": "Google Drive integration not available",
|
||||
"openFiles": "Open Files",
|
||||
"openFile": "Open File",
|
||||
|
||||
@@ -562,7 +562,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@@ -1712,8 +1712,9 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"save": "Guardar",
|
||||
"saveToBrowser": "Guardar en el navegador",
|
||||
"close": "Cerrar",
|
||||
"filesSelected": "archivos seleccionados",
|
||||
"filesSelected": "{{count}} archivos seleccionados",
|
||||
"noFavourites": "No se agregaron favoritos",
|
||||
"downloadComplete": "Descarga completada",
|
||||
"bored": "¿Aburrido de esperar?",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+23
-17
@@ -4,7 +4,9 @@ import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
import { FilesModalProvider } from "./contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
|
||||
@@ -40,23 +42,27 @@ export default function App() {
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</Suspense>
|
||||
|
||||
@@ -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;
|
||||
@@ -20,7 +22,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
@@ -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';
|
||||
@@ -123,7 +148,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
accept={{}}
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
@@ -147,6 +171,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
</FileManagerProvider>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
import styles from './FileEditor.module.css';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept,
|
||||
multiple = true
|
||||
}: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleCardClick = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
}
|
||||
// Reset input so same files can be selected again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('fileEditor.addFiles', 'Add files')}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t('fileEditor.addFiles', 'Add Files')}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '100%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFileCard;
|
||||
@@ -34,9 +34,9 @@
|
||||
.header {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.0625rem 0.0625rem 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
background: var(--bg-toolbar);
|
||||
@@ -86,14 +86,23 @@
|
||||
}
|
||||
|
||||
.headerIndex {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.kebab {
|
||||
justify-self: end;
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.headerIconButton {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
@@ -216,6 +225,11 @@
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pinned {
|
||||
color: #FFC107 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Unsupported file indicator */
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
@@ -304,4 +318,84 @@
|
||||
/* Light mode selected header stroke override */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: #3B4B6E;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Add File Card Styles
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
background: var(--file-card-bg);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.addFileCard:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-blue-500);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.addFileCard:focus {
|
||||
outline: 2px solid var(--color-blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.addFileHeader {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileHeader {
|
||||
background: var(--color-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.addFileContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.addFileIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-subtle);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileIcon {
|
||||
background: var(--color-blue-50);
|
||||
}
|
||||
|
||||
.addFileText {
|
||||
font-weight: 500;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addFileSubtext {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import AddFileCard from './AddFileCard';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
@@ -36,6 +37,7 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
@@ -171,8 +173,8 @@ const FileEditor = ({
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
// Add files to context and select them automatically
|
||||
await addFiles(allExtractedFiles, { selectFiles: true });
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -286,7 +288,7 @@ const FileEditor = ({
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const handleCloseFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
@@ -308,6 +310,48 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
try {
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
await actions.addStirlingFileStubs(result.extractedStubs);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Failed to extract files from ${file.name}`,
|
||||
body: result.errors.join('\n'),
|
||||
expandable: true,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unzip file:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Error unzipping ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
@@ -347,7 +391,7 @@ const FileEditor = ({
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Box p="md">
|
||||
|
||||
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
@@ -405,6 +449,14 @@ const FileEditor = ({
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
@@ -415,11 +467,12 @@ const FileEditor = ({
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
@@ -437,7 +490,7 @@ const FileEditor = ({
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
@@ -27,11 +29,12 @@ interface FileEditorThumbnailProps {
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
onUnzipFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@@ -41,10 +44,11 @@ const FileEditorThumbnail = ({
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onCloseFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -64,6 +68,9 @@ const FileEditorThumbnail = ({
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -251,18 +258,60 @@ const FileEditorThumbnail = ({
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.kebab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
|
||||
<ActionIcon
|
||||
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Download icon */}
|
||||
<Tooltip label={t('download', 'Download')}>
|
||||
<ActionIcon
|
||||
aria-label={t('download', 'Download')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions overlay */}
|
||||
@@ -287,7 +336,7 @@ const FileEditorThumbnail = ({
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
@@ -299,18 +348,28 @@ const FileEditorThumbnail = ({
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
{isZipFile && onUnzipFile && (
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<UnarchiveIcon fontSize="small" />
|
||||
<span>{t('fileManager.unzip', 'Unzip')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t('delete', 'Delete')}</span>
|
||||
<CloseIcon fontSize="small" />
|
||||
<span>{t('close', 'Close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -324,7 +383,7 @@ const FileEditorThumbnail = ({
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text
|
||||
@@ -350,6 +409,7 @@ const FileEditorThumbnail = ({
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
@@ -376,13 +436,6 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pin indicator (bottom-left) */}
|
||||
{isPinned && (
|
||||
<span className={styles.pinIndicator} aria-hidden>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
const EmptyFilesState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { onLocalFileClick } = useFileManagerContext();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
onLocalFileClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}
|
||||
>
|
||||
{/* Container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '3rem 2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
minWidth: '20rem',
|
||||
maxWidth: '28rem',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{/* No Recent Files Message */}
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center" size="lg">
|
||||
{t('fileManager.noRecentFiles', 'No recent files')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Stirling PDF Logo */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-file-manager)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: isUploadHover ? '2rem' : '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? '100%' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
|
||||
}}
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyFilesState;
|
||||
@@ -26,7 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<ScrollArea style={{ flex: 1 }} p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
||||
{currentFile ? currentFile.name : ''}
|
||||
</Text>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileListItem from './FileListItem';
|
||||
import FileHistoryGroup from './FileHistoryGroup';
|
||||
import EmptyFilesState from './EmptyFilesState';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
@@ -29,6 +29,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,15 +44,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length === 0 ? (
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
<Center style={{ height: '12.5rem' }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
|
||||
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
|
||||
@@ -5,10 +5,12 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileListItemProps {
|
||||
@@ -38,7 +40,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
@@ -93,7 +98,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
@@ -192,6 +197,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unzip option for ZIP files */}
|
||||
{isZipFile && !isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnzipFile(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.unzip', 'Unzip')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -5,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>
|
||||
|
||||
@@ -9,7 +9,6 @@ const HiddenFileInput: React.FC = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept={["*/*"] as any}
|
||||
onChange={onFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { HotkeyBinding } from '../../utils/hotkeys';
|
||||
import { useHotkeys } from '../../contexts/HotkeyContext';
|
||||
|
||||
interface HotkeyDisplayProps {
|
||||
binding: HotkeyBinding | null | undefined;
|
||||
size?: 'sm' | 'md';
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const baseKeyStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'var(--mantine-color-gray-1)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '0.125rem 0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1,
|
||||
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
|
||||
minWidth: '1.35rem',
|
||||
color: 'var(--mantine-color-text)',
|
||||
};
|
||||
|
||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
|
||||
const { getDisplayParts } = useHotkeys();
|
||||
const parts = getDisplayParts(binding);
|
||||
|
||||
if (!binding || parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyStyle = size === 'md'
|
||||
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
|
||||
: baseKeyStyle;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||
fontWeight: muted ? 500 : 600,
|
||||
}}
|
||||
>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={`${part}-${index}`}>
|
||||
<kbd style={keyStyle}>{part}</kbd>
|
||||
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeyDisplay;
|
||||
@@ -146,10 +146,12 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
/>
|
||||
{activeFiles.length > 0 && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
@@ -159,6 +161,7 @@ export default function Workbench() {
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
||||
@@ -317,6 +317,7 @@ const FileThumbnail = ({
|
||||
>
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
|
||||
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
import './PageEditor.module.css';
|
||||
import PageThumbnail from './PageThumbnail';
|
||||
@@ -524,66 +526,38 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// Step 2: Check if we have multiple documents (splits) or single document
|
||||
if (Array.isArray(processedDocuments)) {
|
||||
// Multiple documents (splits) - export as ZIP
|
||||
const blobs: Blob[] = [];
|
||||
const filenames: string[] = [];
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const baseExportFilename = getExportFilename();
|
||||
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
const partFilename = `${baseName}_part_${i + 1}.pdf`;
|
||||
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
|
||||
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
|
||||
blobs.push(result.blob);
|
||||
filenames.push(result.filename);
|
||||
}
|
||||
|
||||
// Create ZIP file
|
||||
// Step 3: Download
|
||||
if (files.length > 1) {
|
||||
// Multiple files - create ZIP
|
||||
const JSZip = await import('jszip');
|
||||
const zip = new JSZip.default();
|
||||
|
||||
blobs.forEach((blob, index) => {
|
||||
zip.file(filenames[index], blob);
|
||||
files.forEach((file) => {
|
||||
zip.file(file.name, file);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
|
||||
const exportFilename = getExportFilename();
|
||||
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
} else {
|
||||
// Single document - regular export
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(
|
||||
processedDocuments,
|
||||
sourceFiles,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
)
|
||||
: await pdfExportService.exportPDF(
|
||||
processedDocuments,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
);
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
// Single file - download directly
|
||||
const file = files[0];
|
||||
pdfExportService.downloadFile(file, file.name);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
@@ -592,21 +566,39 @@ const PageEditor = ({
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(() => {
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
);
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
|
||||
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
|
||||
setEditedDocument(documentToSet);
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions]);
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
@@ -670,7 +662,7 @@ const PageEditor = ({
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
@@ -793,7 +785,7 @@ const PageEditor = ({
|
||||
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
applyChanges();
|
||||
await applyChanges();
|
||||
}}
|
||||
onExportAndContinue={async () => {
|
||||
await onExportAll();
|
||||
|
||||
@@ -371,9 +371,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
data-original-rotation={page.rotation}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
@@ -17,32 +17,34 @@ export class RotatePageCommand extends DOMCommand {
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Only update DOM for immediate visual feedback
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property to match the animated CSS
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
let newRotation = currentRotation + this.degrees;
|
||||
|
||||
newRotation = ((newRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Only update DOM
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const previousRotation = currentRotation - this.degrees;
|
||||
let previousRotation = currentRotation - this.degrees;
|
||||
|
||||
previousRotation = ((previousRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${previousRotation}deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/* AppConfigModal styles */
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 37.5rem; /* 600px */
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 15rem; /* 240px */
|
||||
height: 37.5rem; /* 600px */
|
||||
border-top-left-radius: 0.75rem; /* 12px */
|
||||
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile: compact icon-only navigation */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-container {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 5rem; /* 80px - wider for larger icons */
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item.mobile {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-nav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-nav-section-items {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.625rem; /* 8px 10px */
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
height: 37.5rem; /* 600px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-content-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-content-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import Overview from './config/configSections/Overview';
|
||||
import { createConfigNavSections } from './config/configNavSections';
|
||||
import { NavKey } from './config/types';
|
||||
import './AppConfigModal.css';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -8,131 +13,143 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const { config, loading, error, refetch } = useAppConfig();
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Group key={key} wrap="nowrap" align="flex-start">
|
||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||
{key}:
|
||||
</Text>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||
{value ? 'true' : 'false'}
|
||||
</Badge>
|
||||
) : typeof value === 'object' ? (
|
||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||
) : (
|
||||
String(value) || 'null'
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
sectionTitle: 'var(--modal-nav-section-title)',
|
||||
navItem: 'var(--modal-nav-item)',
|
||||
navItemActive: 'var(--modal-nav-item-active)',
|
||||
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
||||
contentBg: 'var(--modal-content-bg)',
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
|
||||
const handleLogout = () => {
|
||||
// In SaaS this would sign out, in open-source it does nothing
|
||||
console.log('Logout placeholder for SaaS compatibility');
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : null;
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return '';
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const systemConfig = config ? {
|
||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||
enableAnalytics: config.enableAnalytics,
|
||||
} : null;
|
||||
|
||||
const premiumConfig = config ? {
|
||||
premiumEnabled: config.premiumEnabled,
|
||||
premiumKey: config.premiumKey ? '***hidden***' : null,
|
||||
runningProOrHigher: config.runningProOrHigher,
|
||||
runningEE: config.runningEE,
|
||||
license: config.license,
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
const legalConfig = config ? {
|
||||
termsAndConditions: config.termsAndConditions,
|
||||
privacyPolicy: config.privacyPolicy,
|
||||
cookiePolicy: config.cookiePolicy,
|
||||
impressum: config.impressum,
|
||||
accessibilityStatement: config.accessibilityStatement,
|
||||
} : null;
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="App Configuration (Testing)"
|
||||
size="lg"
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
style={{ zIndex: 1000 }}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
This modal shows the current application configuration for testing purposes only.
|
||||
</Text>
|
||||
<Button size="xs" variant="light" onClick={refetch}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map(section => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<ScrollArea h={400}>
|
||||
<Stack gap="lg">
|
||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||
{renderConfigSection('Security Configuration', securityConfig)}
|
||||
{renderConfigSection('System Configuration', systemConfig)}
|
||||
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
|
||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||
{renderConfigSection('Legal Configuration', legalConfig)}
|
||||
|
||||
{config.error && (
|
||||
<Alert color="yellow" title="Configuration Warning">
|
||||
{config.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
|
||||
<Code block style={{ fontSize: '11px' }}>
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</Code>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{activeComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
|
||||
const FileUploadButton = ({
|
||||
file,
|
||||
onChange,
|
||||
accept = "*/*",
|
||||
accept,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
variant = "outline",
|
||||
|
||||
@@ -41,7 +41,6 @@ const LandingPage = () => {
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
style={{
|
||||
@@ -178,7 +177,6 @@ const LandingPage = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.zip"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
@@ -269,8 +269,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
|
||||
const isDisabled = !isEnglishGB;
|
||||
// Enable languages with >90% translation completion
|
||||
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
|
||||
const isDisabled = !enabledLanguages.includes(option.value);
|
||||
|
||||
return (
|
||||
<LanguageItem
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
onExportAndContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
|
||||
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
cancelNavigation,
|
||||
confirmNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useNavigationGuard();
|
||||
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
@@ -30,7 +24,7 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const _handleApplyAndContinue = async () => {
|
||||
const handleApplyAndContinue = async () => {
|
||||
if (onApplyAndContinue) {
|
||||
await onApplyAndContinue();
|
||||
}
|
||||
@@ -38,13 +32,14 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
const _handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
const BUTTON_WIDTH = "10rem";
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
@@ -56,54 +51,53 @@ const NavigationWarningModal = ({
|
||||
onClose={handleKeepWorking}
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
centered
|
||||
size="lg"
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
|
||||
<Stack>
|
||||
<Stack ta="center" p="md">
|
||||
<Text size="md" fw="300">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
|
||||
</Text>
|
||||
<Text size="lg" fw="500" >
|
||||
{t("areYouSure", "Are you sure you want to leave?")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
|
||||
{/* Desktop layout: 2 groups side by side */}
|
||||
<Group justify="space-between" gap="xl" visibleFrom="md">
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="var(--mantine-color-gray-8)"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
|
||||
{/* TODO:: Add this back in when it works */}
|
||||
{/* {onApplyAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
>
|
||||
{t("applyAndContinue", "Apply & Continue")}
|
||||
</Button>
|
||||
)} */}
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
{t("exportAndContinue", "Export & Continue")}
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Mobile layout: centered stack of 4 buttons */}
|
||||
<Stack align="center" gap="sm" hiddenFrom="md">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ButtonConfig } from '../../types/sidebar';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -24,6 +26,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -150,8 +153,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
//},
|
||||
{
|
||||
id: 'config',
|
||||
name: t("quickAccess.config", "Config"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
@@ -217,7 +220,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{/* {buttonConfigs
|
||||
{buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||
@@ -237,14 +240,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
))} */}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <AppConfigModal
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
import { SearchInterface } from '../viewer/SearchInterface';
|
||||
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
|
||||
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
|
||||
@@ -293,6 +294,9 @@ export default function RightRail() {
|
||||
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Controls */}
|
||||
<ViewerAnnotationControls currentView={currentView} />
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
|
||||
interface ToolIconProps {
|
||||
icon: React.ReactNode;
|
||||
opacity?: number;
|
||||
color?: string;
|
||||
marginRight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared icon component for consistent tool icon styling across the application.
|
||||
* Uses the same visual pattern as ToolButton: scaled to 0.8, centered transform, consistent spacing.
|
||||
*/
|
||||
export const ToolIcon: React.FC<ToolIconProps> = ({
|
||||
icon,
|
||||
opacity = 1,
|
||||
color = "var(--tools-text-and-icon-color)",
|
||||
marginRight = "0.5rem"
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="tool-button-icon"
|
||||
style={{
|
||||
color,
|
||||
marginRight,
|
||||
transform: "scale(0.8)",
|
||||
transformOrigin: "center",
|
||||
opacity
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { NavKey } from './types';
|
||||
import HotkeysSection from './configSections/HotkeysSection';
|
||||
import GeneralSection from './configSections/GeneralSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
|
||||
export interface ConfigColors {
|
||||
navBg: string;
|
||||
sectionTitle: string;
|
||||
navItem: string;
|
||||
navItemActive: string;
|
||||
navItemActiveBg: string;
|
||||
contentBg: string;
|
||||
headerBorder: string;
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||
onLogoutClick: () => void
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
icon: 'person-rounded',
|
||||
component: <Overview onLogoutClick={onLogoutClick} />
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
{
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
icon: 'settings-rounded',
|
||||
component: <GeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'hotkeys',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'keyboard-rounded',
|
||||
component: <HotkeysSection />
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePreferences } from '../../../../contexts/PreferencesContext';
|
||||
|
||||
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||
|
||||
const GeneralSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
|
||||
|
||||
// Sync local state with preference changes
|
||||
useEffect(() => {
|
||||
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||
}, [preferences.autoUnzipFileLimit]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.description', 'Configure general application preferences.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.autoUnzip}
|
||||
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={fileLimitInput}
|
||||
onChange={setFileLimitInput}
|
||||
onBlur={() => {
|
||||
const numValue = Number(fileLimitInput);
|
||||
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
|
||||
setFileLimitInput(finalValue);
|
||||
updatePreference('autoUnzipFileLimit', finalValue);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!preferences.autoUnzip}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSection;
|
||||
@@ -0,0 +1,173 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
|
||||
import { useHotkeys } from '../../../../contexts/HotkeyContext';
|
||||
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
|
||||
import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
|
||||
const rowHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
|
||||
const HotkeysSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
|
||||
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) {
|
||||
return;
|
||||
}
|
||||
pauseHotkeys();
|
||||
return () => {
|
||||
resumeHotkeys();
|
||||
};
|
||||
}, [editingTool, pauseHotkeys, resumeHotkeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setEditingTool(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = eventToBinding(event as KeyboardEvent);
|
||||
if (!binding) {
|
||||
const osKey = isMac ? 'mac' : 'windows';
|
||||
const fallbackText = isMac
|
||||
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
|
||||
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
|
||||
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
|
||||
toolId !== editingTool && bindingEquals(existing, binding)
|
||||
));
|
||||
|
||||
if (conflictEntry) {
|
||||
const conflictTool = toolRegistry[conflictEntry[0]]?.name ?? conflictEntry[0];
|
||||
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
|
||||
return;
|
||||
}
|
||||
|
||||
updateHotkey(editingTool, binding);
|
||||
setEditingTool(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
|
||||
|
||||
const handleStartCapture = (toolId: ToolId) => {
|
||||
setEditingTool(toolId);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
{tools.map(([toolId, tool], index) => {
|
||||
const currentBinding = hotkeys[toolId];
|
||||
const defaultBinding = defaults[toolId];
|
||||
const isEditing = editingTool === toolId;
|
||||
const defaultParts = getDisplayParts(defaultBinding);
|
||||
const defaultLabel = defaultParts.length > 0
|
||||
? defaultParts.join(' + ')
|
||||
: t('settings.hotkeys.none', 'Not assigned');
|
||||
|
||||
return (
|
||||
<React.Fragment key={toolId}>
|
||||
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
|
||||
<div style={rowHeaderStyle}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
|
||||
<Text fw={600}>{tool.name}</Text>
|
||||
<Group gap="xs" wrap="wrap" align="center">
|
||||
<HotkeyDisplay binding={currentBinding} size="md" />
|
||||
{!bindingEquals(currentBinding, defaultBinding) && (
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{t('settings.hotkeys.customBadge', 'Custom')}
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={isEditing ? 'filled' : 'default'}
|
||||
color={isEditing ? 'blue' : undefined}
|
||||
onClick={() => handleStartCapture(toolId)}
|
||||
>
|
||||
{isEditing
|
||||
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
|
||||
: t('settings.hotkeys.change', 'Change shortcut')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={bindingEquals(currentBinding, defaultBinding)}
|
||||
onClick={() => resetHotkey(toolId)}
|
||||
>
|
||||
{t('settings.hotkeys.reset', 'Reset')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{isEditing && error && (
|
||||
<Alert color="red" radius="sm" variant="filled">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{index < tools.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeysSection;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../../../hooks/useAppConfig';
|
||||
|
||||
const Overview: React.FC = () => {
|
||||
const { config, loading, error } = useAppConfig();
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Group key={key} wrap="nowrap" align="flex-start">
|
||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||
{key}:
|
||||
</Text>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||
{value ? 'true' : 'false'}
|
||||
</Badge>
|
||||
) : typeof value === 'object' ? (
|
||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||
) : (
|
||||
String(value) || 'null'
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : null;
|
||||
|
||||
const systemConfig = config ? {
|
||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||
enableAnalytics: config.enableAnalytics,
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Application Configuration</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Current application settings and configuration details.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{config && (
|
||||
<>
|
||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||
{renderConfigSection('Security Configuration', securityConfig)}
|
||||
{renderConfigSection('System Configuration', systemConfig)}
|
||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||
|
||||
{config.error && (
|
||||
<Alert color="yellow" title="Configuration Warning">
|
||||
{config.error}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
@@ -0,0 +1,19 @@
|
||||
export type NavKey =
|
||||
| 'overview'
|
||||
| 'preferences'
|
||||
| 'notifications'
|
||||
| 'connections'
|
||||
| 'general'
|
||||
| 'people'
|
||||
| 'teams'
|
||||
| 'security'
|
||||
| 'identity'
|
||||
| 'plan'
|
||||
| 'payments'
|
||||
| 'requests'
|
||||
| 'developer'
|
||||
| 'api-keys'
|
||||
| 'hotkeys';
|
||||
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
@@ -19,7 +19,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
children
|
||||
}) => {
|
||||
if (!file) return null;
|
||||
|
||||
|
||||
const containerStyle = {
|
||||
position: 'relative' as const,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
@@ -36,6 +36,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Image
|
||||
className='ph-no-capture'
|
||||
src={thumbnail}
|
||||
alt={`Preview of ${file.name}`}
|
||||
fit="contain"
|
||||
@@ -49,11 +50,12 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
|
||||
<PictureAsPdfIcon
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
<PictureAsPdfIcon
|
||||
className='ph-no-capture'
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
{children}
|
||||
@@ -61,4 +63,4 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentThumbnail;
|
||||
export default DocumentThumbnail;
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
}
|
||||
|
||||
.right-rail-slot.visible {
|
||||
max-height: 18rem; /* increased to fit additional controls + divider */
|
||||
max-height: 40rem; /* increased to fit additional controls + divider */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -77,14 +77,14 @@
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 18rem;
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rightRailShrinkUp {
|
||||
0% {
|
||||
max-height: 18rem;
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '../LocalIcon';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
import { ViewerContext } from '../../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../../contexts/SignatureContext';
|
||||
import { ColorSwatchButton, ColorPicker } from '../../annotation/shared/ColorPicker';
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
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) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
}
|
||||
}, [currentView, viewerContext]);
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Mode Toggle with Drawing Controls */}
|
||||
{viewerContext?.isAnnotationMode ? (
|
||||
// When active: Show color picker on hover
|
||||
<div
|
||||
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
|
||||
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<Popover
|
||||
opened={isHoverColorPickerOpen}
|
||||
onClose={() => setIsHoverColorPickerOpen(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '8rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
size={32}
|
||||
onClick={() => {
|
||||
setIsHoverColorPickerOpen(false); // Close hover picker
|
||||
setIsColorPickerOpen(true); // Open main color picker modal
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
// When inactive: Show "Draw" tooltip
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
// Activate ink drawing tool when entering annotation mode
|
||||
if (signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Save PDF with Annotations */}
|
||||
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={async () => {
|
||||
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
|
||||
try {
|
||||
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
|
||||
if (pdfArrayBuffer) {
|
||||
// Create new File object with flattened annotations
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
// Get the original file name or use a default
|
||||
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
|
||||
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
|
||||
|
||||
// Replace the current file in context with the saved version (exact same logic as Sign tool)
|
||||
if (activeFiles.length > 0) {
|
||||
// Generate thumbnail and metadata for the saved file
|
||||
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
|
||||
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
|
||||
|
||||
// Get current file info
|
||||
const currentFileIds = state.files.ids;
|
||||
if (currentFileIds.length > 0) {
|
||||
const currentFileId = currentFileIds[0];
|
||||
const currentRecord = selectors.getStirlingFileStub(currentFileId);
|
||||
|
||||
if (!currentRecord) {
|
||||
console.error('No file record found for:', currentFileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create output stub and file (exact same as Sign tool)
|
||||
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
|
||||
|
||||
// Replace the original file with the saved version
|
||||
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving PDF:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={(color) => {
|
||||
setSelectedColor(color);
|
||||
// Update drawing tool color if annotation mode is active
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.updateDrawSettings(color, 2);
|
||||
} catch (error) {
|
||||
console.log('Unable to update drawing settings:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Choose Drawing Color"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Slider, Text, Group, NumberInput } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export default function SliderWithInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
min = 0,
|
||||
max = 200,
|
||||
step = 1,
|
||||
}: Props) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text>
|
||||
<Group gap="sm" align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(Number(v) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import { useToolSections } from '../../hooks/useToolSections';
|
||||
import SubcategoryHeader from './shared/SubcategoryHeader';
|
||||
import NoToolsFound from './shared/NoToolsFound';
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
interface SearchResultsProps {
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
|
||||
onSelect: (id: string) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
@@ -40,13 +41,13 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect,
|
||||
{group.tools.map(({ id, tool }) => {
|
||||
const matchedText = matchedTextMap.get(id);
|
||||
// Check if the match was from synonyms and show the actual synonym that matched
|
||||
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
|
||||
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
);
|
||||
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
|
||||
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
) : undefined;
|
||||
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
|
||||
@@ -6,11 +6,12 @@ import "./toolPicker/ToolPicker.css";
|
||||
import { useToolSections } from "../../hooks/useToolSections";
|
||||
import NoToolsFound from "./shared/NoToolsFound";
|
||||
import { renderToolButtons } from "./shared/renderToolButtons";
|
||||
import { ToolId } from "src/types/toolId";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Suspense } from "react";
|
||||
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
|
||||
import { BaseToolProps } from "../../types/tool";
|
||||
import ToolLoadingFallback from "./ToolLoadingFallback";
|
||||
import { ToolId } from "src/types/toolId";
|
||||
|
||||
interface ToolRendererProps extends BaseToolProps {
|
||||
selectedToolKey: string;
|
||||
selectedToolKey: ToolId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* AddAttachmentsSettings - Shared settings component for both tool UI and automation
|
||||
*
|
||||
* Allows selecting files to attach to PDFs.
|
||||
*/
|
||||
|
||||
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddAttachmentsParameters } from "../../../hooks/tools/addAttachments/useAddAttachmentsParameters";
|
||||
import LocalIcon from "../../shared/LocalIcon";
|
||||
|
||||
interface AddAttachmentsSettingsProps {
|
||||
parameters: AddAttachmentsParameters;
|
||||
onParameterChange: <K extends keyof AddAttachmentsParameters>(key: K, value: AddAttachmentsParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = false }: AddAttachmentsSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
|
||||
</Text>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
// Append to existing attachments instead of replacing
|
||||
const newAttachments = [...(parameters.attachments || []), ...files];
|
||||
onParameterChange('attachments', newAttachments);
|
||||
// Reset the input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ display: 'none' }}
|
||||
id="attachments-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
component="label"
|
||||
htmlFor="attachments-input"
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
|
||||
>
|
||||
{parameters.attachments?.length > 0
|
||||
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
|
||||
: t("AddAttachmentsRequest.placeholder", "Choose files...")
|
||||
}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{parameters.attachments?.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({parameters.attachments.length})
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
|
||||
<Stack gap="xs">
|
||||
{parameters.attachments.map((file, index) => (
|
||||
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
|
||||
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
|
||||
{/* Filename (two-line clamp, wraps, no icon on the left) */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2 as any,
|
||||
WebkitBoxOrient: 'vertical' as any,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => {
|
||||
const newAttachments = (parameters.attachments || []).filter((_, i) => i !== index);
|
||||
onParameterChange('attachments', newAttachments);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="14" height="14" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAttachmentsSettings;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AddPageNumbersAppearanceSettings - Customize Appearance step
|
||||
*/
|
||||
|
||||
import { Stack, Select, TextInput, NumberInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
|
||||
interface AddPageNumbersAppearanceSettingsProps {
|
||||
parameters: AddPageNumbersParameters;
|
||||
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddPageNumbersAppearanceSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: AddPageNumbersAppearanceSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
|
||||
<Select
|
||||
label={t('addPageNumbers.selectText.2', 'Margin')}
|
||||
value={parameters.customMargin}
|
||||
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
|
||||
data={[
|
||||
{ value: 'small', label: t('sizes.small', 'Small') },
|
||||
{ value: 'medium', label: t('sizes.medium', 'Medium') },
|
||||
{ value: 'large', label: t('sizes.large', 'Large') },
|
||||
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
|
||||
<NumberInput
|
||||
label={t('addPageNumbers.fontSize', 'Font Size')}
|
||||
value={parameters.fontSize}
|
||||
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 12)}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
|
||||
<Select
|
||||
label={t('addPageNumbers.fontName', 'Font Type')}
|
||||
value={parameters.fontType}
|
||||
onChange={(v) => onParameterChange('fontType', (v as any) || 'Times')}
|
||||
data={[
|
||||
{ value: 'Times', label: 'Times Roman' },
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Courier', label: 'Courier New' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
|
||||
<TextInput
|
||||
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
|
||||
value={parameters.customText || ''}
|
||||
onChange={(e) => onParameterChange('customText', e.currentTarget.value)}
|
||||
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPageNumbersAppearanceSettings;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* AddPageNumbersAutomationSettings - Used for automation only
|
||||
*
|
||||
* Combines both position and appearance settings into a single view
|
||||
*/
|
||||
|
||||
import { Stack, Divider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
|
||||
import AddPageNumbersPositionSettings from "./AddPageNumbersPositionSettings";
|
||||
import AddPageNumbersAppearanceSettings from "./AddPageNumbersAppearanceSettings";
|
||||
|
||||
interface AddPageNumbersAutomationSettingsProps {
|
||||
parameters: AddPageNumbersParameters;
|
||||
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddPageNumbersAutomationSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: AddPageNumbersAutomationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Position & Pages Section */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>{t("addPageNumbers.positionAndPages", "Position & Pages")}</Text>
|
||||
<AddPageNumbersPositionSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
file={null}
|
||||
showQuickGrid={true}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>{t("addPageNumbers.customize", "Customize Appearance")}</Text>
|
||||
<AddPageNumbersAppearanceSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPageNumbersAutomationSettings;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* AddPageNumbersPositionSettings - Position & Pages step
|
||||
*/
|
||||
|
||||
import { Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
import PageNumberPreview from "./PageNumberPreview";
|
||||
|
||||
interface AddPageNumbersPositionSettingsProps {
|
||||
parameters: AddPageNumbersParameters;
|
||||
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
file?: File | null;
|
||||
showQuickGrid?: boolean;
|
||||
}
|
||||
|
||||
const AddPageNumbersPositionSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
file = null,
|
||||
showQuickGrid = true
|
||||
}: AddPageNumbersPositionSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Position Selection */}
|
||||
<Stack gap="md">
|
||||
<PageNumberPreview
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
file={file}
|
||||
showQuickGrid={showQuickGrid}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pages & Starting Number Section */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
|
||||
|
||||
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
|
||||
<TextInput
|
||||
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
|
||||
value={parameters.pagesToNumber || ''}
|
||||
onChange={(e) => onParameterChange('pagesToNumber', e.currentTarget.value)}
|
||||
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
|
||||
<NumberInput
|
||||
label={t('addPageNumbers.selectText.4', 'Starting Number')}
|
||||
value={parameters.startingNumber}
|
||||
onChange={(v) => onParameterChange('startingNumber', typeof v === 'number' ? v : 1)}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPageNumbersPositionSettings;
|
||||
@@ -200,7 +200,7 @@ export default function PageNumberPreview({ parameters, onParameterChange, file,
|
||||
<img
|
||||
src={pageThumbnail}
|
||||
alt="page preview"
|
||||
className={styles.pageThumbnail}
|
||||
className={`${styles.pageThumbnail} ph-no-capture`}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
@@ -238,4 +238,4 @@ export default function PageNumberPreview({ parameters, onParameterChange, file,
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* AddStampAutomationSettings - Used for automation only
|
||||
*
|
||||
* This component combines all stamp settings into a single step interface
|
||||
* for use in the automation system. It includes setup and formatting
|
||||
* settings in one unified component.
|
||||
*/
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { AddStampParameters } from "./useAddStampParameters";
|
||||
import StampSetupSettings from "./StampSetupSettings";
|
||||
import StampPositionFormattingSettings from "./StampPositionFormattingSettings";
|
||||
|
||||
interface AddStampAutomationSettingsProps {
|
||||
parameters: AddStampParameters;
|
||||
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddStampAutomationSettings = ({ parameters, onParameterChange, disabled = false }: AddStampAutomationSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Stamp Setup (Type, Text/Image, Page Selection) */}
|
||||
<StampSetupSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Position and Formatting Settings */}
|
||||
{parameters.stampType && (
|
||||
<StampPositionFormattingSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
showPositionGrid={true}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddStampAutomationSettings;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Group, Select, Stack, ColorInput, Button, Slider, Text, NumberInput } from "@mantine/core";
|
||||
import { AddStampParameters } from "./useAddStampParameters";
|
||||
import LocalIcon from "../../shared/LocalIcon";
|
||||
import styles from "./StampPreview.module.css";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
|
||||
interface StampPositionFormattingSettingsProps {
|
||||
parameters: AddStampParameters;
|
||||
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
showPositionGrid?: boolean; // When true, show the 9-position grid for automation
|
||||
}
|
||||
|
||||
const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabled = false, showPositionGrid = false }: StampPositionFormattingSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md" justify="space-between">
|
||||
{/* Position Grid - shown in automation settings */}
|
||||
{showPositionGrid && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{t('AddStampRequest.position', 'Stamp Position')}</Text>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '0.5rem',
|
||||
maxWidth: '200px'
|
||||
}}>
|
||||
{Array.from({ length: 9 }).map((_, i) => {
|
||||
const idx = (i + 1) as 1|2|3|4|5|6|7|8|9;
|
||||
const selected = parameters.position === idx;
|
||||
return (
|
||||
<Button
|
||||
key={idx}
|
||||
variant={selected ? 'filled' : 'outline'}
|
||||
onClick={() => {
|
||||
onParameterChange('position', idx);
|
||||
// Ensure we're using grid positioning, not custom overrides
|
||||
onParameterChange('overrideX', -1 as any);
|
||||
onParameterChange('overrideY', -1 as any);
|
||||
}}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
height: '50px',
|
||||
padding: '0',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{idx}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
{/* Icon pill buttons row */}
|
||||
<div className="flex justify-between gap-[0.5rem]">
|
||||
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
|
||||
<Button
|
||||
variant={parameters._activePill === 'rotation' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => onParameterChange('_activePill', 'rotation')}
|
||||
>
|
||||
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
|
||||
<Button
|
||||
variant={parameters._activePill === 'opacity' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => onParameterChange('_activePill', 'opacity')}
|
||||
>
|
||||
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
|
||||
<Button
|
||||
variant={parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => onParameterChange('_activePill', 'fontSize')}
|
||||
>
|
||||
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Single slider bound to selected pill */}
|
||||
{parameters._activePill === 'fontSize' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{parameters.stampType === 'image'
|
||||
? t('AddStampRequest.imageSize', 'Image Size')
|
||||
: t('AddStampRequest.fontSize', 'Font Size')
|
||||
}
|
||||
</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.fontSize}
|
||||
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 1)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.fontSize}
|
||||
onChange={(v) => onParameterChange('fontSize', v as number)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === 'rotation' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.rotation}
|
||||
onChange={(v) => onParameterChange('rotation', typeof v === 'number' ? v : 0)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
hideControls
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.rotation}
|
||||
onChange={(v) => onParameterChange('rotation', v as number)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
className={styles.sliderWide}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === 'opacity' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.opacity}
|
||||
onChange={(v) => onParameterChange('opacity', typeof v === 'number' ? v : 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.opacity}
|
||||
onChange={(v) => onParameterChange('opacity', v as number)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{parameters.stampType !== 'image' && (
|
||||
<ColorInput
|
||||
label={t('AddStampRequest.customColor', 'Custom Text Color')}
|
||||
value={parameters.customColor}
|
||||
onChange={(value) => onParameterChange('customColor', value)}
|
||||
format="hex"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Margin selection for text stamps */}
|
||||
{parameters.stampType === 'text' && (
|
||||
<Select
|
||||
label={t('AddStampRequest.margin', 'Margin')}
|
||||
value={parameters.customMargin}
|
||||
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
|
||||
data={[
|
||||
{ value: 'small', label: t('margin.small', 'Small') },
|
||||
{ value: 'medium', label: t('margin.medium', 'Medium') },
|
||||
{ value: 'large', label: t('margin.large', 'Large') },
|
||||
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StampPositionFormattingSettings;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core";
|
||||
import { AddStampParameters } from "./useAddStampParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
import styles from "./StampPreview.module.css";
|
||||
import { getDefaultFontSizeForAlphabet } from "./StampPreviewUtils";
|
||||
|
||||
interface StampSetupSettingsProps {
|
||||
parameters: AddStampParameters;
|
||||
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }: StampSetupSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
|
||||
value={parameters.pageNumbers}
|
||||
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Divider/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
|
||||
<ButtonSelector
|
||||
value={parameters.stampType}
|
||||
onChange={(v: 'text' | 'image') => onParameterChange('stampType', v)}
|
||||
options={[
|
||||
{ value: 'text', label: t('watermark.type.1', 'Text') },
|
||||
{ value: 'image', label: t('watermark.type.2', 'Image') },
|
||||
]}
|
||||
disabled={disabled}
|
||||
buttonClassName={styles.modeToggleButton}
|
||||
textClassName={styles.modeToggleButtonText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{parameters.stampType === 'text' && (
|
||||
<>
|
||||
<Textarea
|
||||
label={t('AddStampRequest.stampText', 'Stamp Text')}
|
||||
value={parameters.stampText}
|
||||
onChange={(e) => onParameterChange('stampText', e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
label={t('AddStampRequest.alphabet', 'Alphabet')}
|
||||
value={parameters.alphabet}
|
||||
onChange={(v) => {
|
||||
const nextAlphabet = (v as any) || 'roman';
|
||||
onParameterChange('alphabet', nextAlphabet);
|
||||
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
|
||||
onParameterChange('fontSize', nextDefault);
|
||||
}}
|
||||
data={[
|
||||
{ value: 'roman', label: 'Roman' },
|
||||
{ value: 'arabic', label: 'العربية' },
|
||||
{ value: 'japanese', label: '日本語' },
|
||||
{ value: 'korean', label: '한국어' },
|
||||
{ value: 'chinese', label: '简体中文' },
|
||||
{ value: 'thai', label: 'ไทย' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{parameters.stampType === 'image' && (
|
||||
<Stack gap="xs">
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onParameterChange('stampImage', file);
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ display: 'none' }}
|
||||
id="stamp-image-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
component="label"
|
||||
htmlFor="stamp-image-input"
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('chooseFile', 'Choose File')}
|
||||
</Button>
|
||||
{parameters.stampImage && (
|
||||
<Stack gap="xs">
|
||||
<img
|
||||
src={URL.createObjectURL(parameters.stampImage)}
|
||||
alt="Selected stamp image"
|
||||
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{parameters.stampImage.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StampSetupSettings;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
|
||||
|
||||
interface Props {
|
||||
parameters: AdjustContrastParameters;
|
||||
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdjustContrastBasicSettings({ parameters, onParameterChange, disabled }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SliderWithInput label={t('adjustContrast.contrast', 'Contrast')} value={parameters.contrast} onChange={(v) => onParameterChange('contrast', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.brightness', 'Brightness')} value={parameters.brightness} onChange={(v) => onParameterChange('brightness', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.saturation', 'Saturation')} value={parameters.saturation} onChange={(v) => onParameterChange('saturation', v as any)} disabled={disabled} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
|
||||
|
||||
interface Props {
|
||||
parameters: AdjustContrastParameters;
|
||||
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdjustContrastColorSettings({ parameters, onParameterChange, disabled }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SliderWithInput label={t('adjustContrast.red', 'Red')} value={parameters.red} onChange={(v) => onParameterChange('red', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.green', 'Green')} value={parameters.green} onChange={(v) => onParameterChange('green', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.blue', 'Blue')} value={parameters.blue} onChange={(v) => onParameterChange('blue', v as any)} disabled={disabled} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
|
||||
import ObscuredOverlay from '../../shared/ObscuredOverlay';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { applyAdjustmentsToCanvas } from './utils';
|
||||
|
||||
interface Props {
|
||||
file: File | null;
|
||||
parameters: AdjustContrastParameters;
|
||||
}
|
||||
|
||||
export default function AdjustContrastPreview({ file, parameters }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [thumb, setThumb] = useState<string | null>(null);
|
||||
const { requestThumbnail } = useThumbnailGeneration();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
if (!file || file.type !== 'application/pdf') { setThumb(null); return; }
|
||||
const id = `${file.name}:${file.size}:${file.lastModified}:page:1`;
|
||||
const tUrl = await requestThumbnail(id, file, 1);
|
||||
if (active) setThumb(tUrl || null);
|
||||
};
|
||||
load();
|
||||
return () => { active = false; };
|
||||
}, [file, requestThumbnail]);
|
||||
|
||||
useEffect(() => {
|
||||
const revoked: string | null = null;
|
||||
const render = async () => {
|
||||
if (!thumb || !canvasRef.current) return;
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = thumb;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject();
|
||||
});
|
||||
|
||||
// Draw thumbnail to a source canvas
|
||||
const src = document.createElement('canvas');
|
||||
src.width = img.naturalWidth;
|
||||
src.height = img.naturalHeight;
|
||||
const sctx = src.getContext('2d');
|
||||
if (!sctx) return;
|
||||
sctx.drawImage(img, 0, 0);
|
||||
|
||||
// Apply accurate pixel adjustments
|
||||
const adjusted = applyAdjustmentsToCanvas(src, parameters);
|
||||
|
||||
// Draw adjusted onto display canvas
|
||||
const display = canvasRef.current;
|
||||
display.width = adjusted.width;
|
||||
display.height = adjusted.height;
|
||||
const dctx = display.getContext('2d');
|
||||
if (!dctx) return;
|
||||
dctx.clearRect(0, 0, display.width, display.height);
|
||||
dctx.drawImage(adjusted, 0, 0);
|
||||
};
|
||||
render();
|
||||
return () => {
|
||||
if (revoked) URL.revokeObjectURL(revoked);
|
||||
};
|
||||
}, [thumb, parameters]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-color-muted)' }}>{t('common.preview', 'Preview')}</div>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
|
||||
</div>
|
||||
<ObscuredOverlay
|
||||
obscured={!thumb}
|
||||
overlayMessage={<Text size="sm" c="white" fw={600}>{t('adjustContrast.noPreview', 'Select a PDF to preview')}</Text>}
|
||||
borderRadius={6}
|
||||
>
|
||||
<div ref={containerRef} style={{ aspectRatio: '8.5/11', width: '100%', border: '1px solid var(--border-color)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
{thumb && (
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</div>
|
||||
</ObscuredOverlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import AdjustContrastBasicSettings from './AdjustContrastBasicSettings';
|
||||
import AdjustContrastColorSettings from './AdjustContrastColorSettings';
|
||||
|
||||
interface Props {
|
||||
parameters: AdjustContrastParameters;
|
||||
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Single-step settings used by Automate to configure Adjust Contrast in one panel
|
||||
export default function AdjustContrastSingleStepSettings({ parameters, onParameterChange, disabled }: Props) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<AdjustContrastBasicSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<AdjustContrastColorSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
|
||||
export function applyAdjustmentsToCanvas(src: HTMLCanvasElement, params: AdjustContrastParameters): HTMLCanvasElement {
|
||||
const out = document.createElement('canvas');
|
||||
out.width = src.width;
|
||||
out.height = src.height;
|
||||
const ctx = out.getContext('2d');
|
||||
if (!ctx) return src;
|
||||
ctx.drawImage(src, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, out.width, out.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const contrast = params.contrast / 100; // 0..2
|
||||
const brightness = params.brightness / 100; // 0..2
|
||||
const saturation = params.saturation / 100; // 0..2
|
||||
const redMul = params.red / 100; // 0..2
|
||||
const greenMul = params.green / 100; // 0..2
|
||||
const blueMul = params.blue / 100; // 0..2
|
||||
|
||||
const clamp = (v: number) => Math.min(255, Math.max(0, v));
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i] * redMul;
|
||||
let g = data[i + 1] * greenMul;
|
||||
let b = data[i + 2] * blueMul;
|
||||
|
||||
// Contrast (centered at 128)
|
||||
r = clamp((r - 128) * contrast + 128);
|
||||
g = clamp((g - 128) * contrast + 128);
|
||||
b = clamp((b - 128) * contrast + 128);
|
||||
|
||||
// Brightness
|
||||
r = clamp(r * brightness);
|
||||
g = clamp(g * brightness);
|
||||
b = clamp(b * brightness);
|
||||
|
||||
// Saturation via HSL
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn);
|
||||
let h = 0, s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
|
||||
case gn: h = (bn - rn) / d + 2; break;
|
||||
default: h = (rn - gn) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
s = Math.min(1, Math.max(0, s * saturation));
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
let r2: number, g2: number, b2: number;
|
||||
if (s === 0) { r2 = g2 = b2 = l; }
|
||||
else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r2 = hue2rgb(p, q, h + 1/3);
|
||||
g2 = hue2rgb(p, q, h);
|
||||
b2 = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
data[i] = clamp(Math.round(r2 * 255));
|
||||
data[i + 1] = clamp(Math.round(g2 * 255));
|
||||
data[i + 2] = clamp(Math.round(b2 * 255));
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
|
||||
import { Group, Text, ActionIcon, Menu, Button, Box } from '@mantine/core';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { ToolIcon } from '../../shared/ToolIcon';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
|
||||
interface AutomationEntryProps {
|
||||
@@ -50,8 +51,7 @@ export default function AutomationEntry({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
const shouldShowMenu = isHovered || isMenuOpen;
|
||||
|
||||
// Helper function to resolve tool display names
|
||||
const getToolDisplayName = (operation: string): string => {
|
||||
@@ -108,134 +108,135 @@ export default function AutomationEntry({
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (title) {
|
||||
// Custom automation with title
|
||||
return (
|
||||
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
|
||||
{BadgeIcon && (
|
||||
<BadgeIcon
|
||||
style={{
|
||||
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
|
||||
const buttonContent = (
|
||||
<>
|
||||
{BadgeIcon && (
|
||||
<ToolIcon
|
||||
icon={<BadgeIcon />}
|
||||
{...(keepIconColor && { color: 'var(--mantine-primary-color-filled)' })}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||
{title ? (
|
||||
// Custom automation with title
|
||||
<Text size="sm" style={{ textAlign: 'left' }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
} else {
|
||||
// Suggested automation showing tool chain
|
||||
return (
|
||||
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
|
||||
{BadgeIcon && (
|
||||
<BadgeIcon
|
||||
style={{
|
||||
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
// Suggested automation showing tool chain
|
||||
<Group gap="xs" justify="flex-start" style={{ flex: 1 }}>
|
||||
{operations.map((op, index) => (
|
||||
<React.Fragment key={`${op}-${index}`}>
|
||||
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
<Text size="sm">
|
||||
{getToolDisplayName(op)}
|
||||
</Text>
|
||||
|
||||
{index < operations.length - 1 && (
|
||||
<Text size="xs" c="dimmed" style={{ color: 'var(--mantine-color-text)' }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
→
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
};
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const boxContent = (
|
||||
const wrapperContent = (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
transition: 'background-color 0.15s ease',
|
||||
padding: '0.75rem 1rem',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={onClick}
|
||||
style={{ position: 'relative', width: '100%' }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Group gap="md" align="center" justify="space-between" style={{ width: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start' }}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: 'visible',
|
||||
backgroundColor: shouldShowMenu ? 'var(--automation-entry-hover-bg)' : undefined,
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--automation-entry-hover-bg)'
|
||||
}
|
||||
},
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
{buttonContent}
|
||||
</Button>
|
||||
{showMenu && (
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0.5rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 1,
|
||||
opacity: shouldShowMenu ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
pointerEvents: shouldShowMenu ? 'auto' : 'none'
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
{showMenu && (
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
onOpen={() => setIsMenuOpen(true)}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
c="dimmed"
|
||||
size="md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
opacity: shouldShowHovered ? 1 : 0,
|
||||
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
|
||||
transition: 'opacity 0.3s ease, transform 0.3s ease',
|
||||
pointerEvents: shouldShowHovered ? 'auto' : 'none'
|
||||
<Menu.Dropdown>
|
||||
{onCopy && (
|
||||
<Menu.Item
|
||||
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{onCopy && (
|
||||
<Menu.Item
|
||||
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
{t('automate.copyToSaved', 'Copy to Saved')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Menu.Item
|
||||
leftSection={<EditIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
{t('edit', 'Edit')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{t('delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
{t('automate.copyToSaved', 'Copy to Saved')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Menu.Item
|
||||
leftSection={<EditIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
{t('edit', 'Edit')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{t('delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -249,9 +250,9 @@ export default function AutomationEntry({
|
||||
arrow={true}
|
||||
delay={500}
|
||||
>
|
||||
{boxContent}
|
||||
{wrapperContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
boxContent
|
||||
wrapperContent
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
|
||||
// Get tool info from registry
|
||||
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
|
||||
const SettingsComponent = toolInfo?.settingsComponent;
|
||||
const SettingsComponent = toolInfo?.automationSettings;
|
||||
|
||||
// Initialize parameters from tool (which should contain defaults from registry)
|
||||
useEffect(() => {
|
||||
@@ -109,7 +109,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
|
||||
</Text>
|
||||
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto', overflowX: "hidden" }}>
|
||||
{renderToolSettings()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,11 +34,14 @@ export default function ToolList({
|
||||
|
||||
const handleToolSelect = (index: number, newOperation: string) => {
|
||||
const defaultParams = getToolDefaultParameters(newOperation);
|
||||
const toolEntry = toolRegistry[newOperation];
|
||||
// If tool has no settingsComponent, it's automatically configured
|
||||
const isConfigured = !toolEntry?.automationSettings;
|
||||
|
||||
onToolUpdate(index, {
|
||||
operation: newOperation,
|
||||
name: getToolName(newOperation),
|
||||
configured: false,
|
||||
configured: isConfigured,
|
||||
parameters: defaultParams,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, ScrollArea } from '@mantine/core';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import { ToolRegistryEntry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy';
|
||||
import { useToolSections } from '../../../hooks/useToolSections';
|
||||
import { renderToolButtons } from '../shared/renderToolButtons';
|
||||
import ToolSearch from '../toolPicker/ToolSearch';
|
||||
import ToolButton from '../toolPicker/ToolButton';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
interface ToolSelectorProps {
|
||||
onSelect: (toolKey: string) => void;
|
||||
@@ -28,9 +29,11 @@ export default function ToolSelector({
|
||||
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter out excluded tools (like 'automate' itself)
|
||||
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
|
||||
const baseFilteredTools = useMemo(() => {
|
||||
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
|
||||
return (Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]).filter(([key, tool]) =>
|
||||
!excludeTools.includes(key) && getToolSupportsAutomate(tool)
|
||||
);
|
||||
}, [toolRegistry, excludeTools]);
|
||||
|
||||
// Apply search filter
|
||||
@@ -64,7 +67,7 @@ export default function ToolSelector({
|
||||
}, [filteredTools]);
|
||||
|
||||
// Use the same tool sections logic as the main ToolPicker
|
||||
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
|
||||
const { sections, searchGroups } = useToolSections(transformedFilteredTools as any /* FIX ME */);
|
||||
|
||||
// Determine what to display: search results or organized sections
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
@@ -154,7 +157,7 @@ export default function ToolSelector({
|
||||
// Show selected tool in AutomationEntry style when tool is selected and dropdown closed
|
||||
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
|
||||
borderRadius: "var(--mantine-radius-lg)" }}>
|
||||
<ToolButton id='tool' tool={toolRegistry[selectedValue]} isSelected={false}
|
||||
<ToolButton id={'tool' as any /* FIX ME */} tool={toolRegistry[selectedValue]} isSelected={false}
|
||||
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* CertSignAutomationSettings - Used for automation only
|
||||
*
|
||||
* This component combines all certificate signing settings into a single step interface
|
||||
* for use in the automation system. It includes sign mode, certificate format, certificate files,
|
||||
* and signature appearance settings in one unified component.
|
||||
*/
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
|
||||
import CertificateTypeSettings from "./CertificateTypeSettings";
|
||||
import CertificateFormatSettings from "./CertificateFormatSettings";
|
||||
import CertificateFilesSettings from "./CertificateFilesSettings";
|
||||
import SignatureAppearanceSettings from "./SignatureAppearanceSettings";
|
||||
|
||||
interface CertSignAutomationSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: <K extends keyof CertSignParameters>(key: K, value: CertSignParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CertSignAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CertSignAutomationSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Sign Mode Selection (Manual vs Auto) */}
|
||||
<CertificateTypeSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Certificate Format - only show for Manual mode */}
|
||||
{parameters.signMode === 'MANUAL' && (
|
||||
<CertificateFormatSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Certificate Files - only show for Manual mode */}
|
||||
{parameters.signMode === 'MANUAL' && (
|
||||
<CertificateFilesSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Signature Appearance Settings */}
|
||||
<SignatureAppearanceSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertSignAutomationSettings;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* CropAutomationSettings - Used for automation only
|
||||
*
|
||||
* Simplified crop settings for automation that doesn't require a file preview.
|
||||
* Allows users to manually enter crop coordinates and dimensions.
|
||||
*/
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { CropParameters } from "../../../hooks/tools/crop/useCropParameters";
|
||||
import { Rectangle } from "../../../utils/cropCoordinates";
|
||||
import CropCoordinateInputs from "./CropCoordinateInputs";
|
||||
|
||||
interface CropAutomationSettingsProps {
|
||||
parameters: CropParameters;
|
||||
onParameterChange: <K extends keyof CropParameters>(key: K, value: CropParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CropAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CropAutomationSettingsProps) => {
|
||||
// Handle coordinate changes
|
||||
const handleCoordinateChange = (field: keyof Rectangle, value: number | string) => {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(numValue)) return;
|
||||
|
||||
const newCropArea = { ...parameters.cropArea, [field]: numValue };
|
||||
onParameterChange('cropArea', newCropArea);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<CropCoordinateInputs
|
||||
cropArea={parameters.cropArea}
|
||||
onCoordinateChange={handleCoordinateChange}
|
||||
disabled={disabled}
|
||||
showAutomationInfo={true}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropAutomationSettings;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Stack, Text, Group, NumberInput, Alert } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rectangle, PDFBounds } from "../../../utils/cropCoordinates";
|
||||
|
||||
interface CropCoordinateInputsProps {
|
||||
cropArea: Rectangle;
|
||||
onCoordinateChange: (field: keyof Rectangle, value: number | string) => void;
|
||||
disabled?: boolean;
|
||||
pdfBounds?: PDFBounds;
|
||||
showAutomationInfo?: boolean;
|
||||
}
|
||||
|
||||
const CropCoordinateInputs = ({
|
||||
cropArea,
|
||||
onCoordinateChange,
|
||||
disabled = false,
|
||||
pdfBounds,
|
||||
showAutomationInfo = false
|
||||
}: CropCoordinateInputsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{showAutomationInfo && (
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.automation.info", "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
{t("crop.coordinates.title", "Position and Size")}
|
||||
</Text>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.x", "X Position")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.x.desc", "Left edge (points)") : undefined}
|
||||
value={Math.round(cropArea.x * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('x', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds?.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.y", "Y Position")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.y.desc", "Bottom edge (points)") : undefined}
|
||||
value={Math.round(cropArea.y * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('y', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds?.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.width", "Width")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.width.desc", "Crop width (points)") : undefined}
|
||||
value={Math.round(cropArea.width * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('width', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds?.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.height", "Height")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.height.desc", "Crop height (points)") : undefined}
|
||||
value={Math.round(cropArea.height * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('height', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds?.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{showAutomationInfo && (
|
||||
<Alert color="gray" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.automation.reference", "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropCoordinateInputs;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { Stack, Text, Box, Group, NumberInput, ActionIcon, Center, Alert } from "@mantine/core";
|
||||
import { Stack, Text, Box, Group, ActionIcon, Center, Alert } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import { CropParametersHook } from "../../../hooks/tools/crop/useCropParameters";
|
||||
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
|
||||
import CropAreaSelector from "./CropAreaSelector";
|
||||
import CropCoordinateInputs from "./CropCoordinateInputs";
|
||||
import { DEFAULT_CROP_AREA } from "../../../constants/cropConstants";
|
||||
import { PAGE_SIZES } from "../../../constants/pageSizeConstants";
|
||||
import {
|
||||
@@ -190,71 +191,22 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
|
||||
</Stack>
|
||||
|
||||
{/* Manual Coordinate Input */}
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("crop.coordinates.title", "Position and Size")}
|
||||
</Text>
|
||||
<CropCoordinateInputs
|
||||
cropArea={cropArea}
|
||||
onCoordinateChange={handleCoordinateChange}
|
||||
disabled={disabled}
|
||||
pdfBounds={pdfBounds}
|
||||
showAutomationInfo={false}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.x", "X Position")}
|
||||
value={Math.round(cropArea.x * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('x', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.y", "Y Position")}
|
||||
value={Math.round(cropArea.y * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('y', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.width", "Width")}
|
||||
value={Math.round(cropArea.width * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('width', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.height", "Height")}
|
||||
value={Math.round(cropArea.height * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('height', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
|
||||
{/* Validation Alert */}
|
||||
{!isCropValid && (
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Validation Alert */}
|
||||
{!isCropValid && (
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,16 +16,17 @@ const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }
|
||||
// Allow user to type naturally - don't normalize input in real-time
|
||||
onParameterChange('pageNumbers', value);
|
||||
};
|
||||
console.log('Current pageNumbers input:', parameters.pageNumbers, disabled);
|
||||
|
||||
// Check if current input is valid
|
||||
const isValid = validatePageNumbers(parameters.pageNumbers);
|
||||
const hasValue = parameters.pageNumbers.trim().length > 0;
|
||||
const isValid = validatePageNumbers(parameters.pageNumbers || '');
|
||||
const hasValue = (parameters?.pageNumbers?.trim().length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('removePages.pageNumbers.label', 'Pages to Remove')}
|
||||
value={parameters.pageNumbers}
|
||||
value={parameters.pageNumbers || ''}
|
||||
onChange={(event) => handlePageNumbersChange(event.currentTarget.value)}
|
||||
placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* RotateAutomationSettings - Used for automation only
|
||||
*
|
||||
* Simplified rotation settings for automation that allows selecting
|
||||
* one of four 90-degree rotation angles.
|
||||
*/
|
||||
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateParameters } from "../../../hooks/tools/rotate/useRotateParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
|
||||
interface RotateAutomationSettingsProps {
|
||||
parameters: RotateParameters;
|
||||
onParameterChange: <K extends keyof RotateParameters>(key: K, value: RotateParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RotateAutomationSettings = ({ parameters, onParameterChange, disabled = false }: RotateAutomationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("rotate.selectRotation", "Select Rotation Angle (Clockwise)")}
|
||||
</Text>
|
||||
|
||||
<ButtonSelector
|
||||
value={parameters.angle}
|
||||
onChange={(value: number) => onParameterChange('angle', value)}
|
||||
options={[
|
||||
{ value: 0, label: "0°" },
|
||||
{ value: 90, label: "90°" },
|
||||
{ value: 180, label: "180°" },
|
||||
{ value: 270, label: "270°" },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RotateAutomationSettings;
|
||||
@@ -62,6 +62,7 @@ const ResultsPreview = ({
|
||||
{/* File name at the top */}
|
||||
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text
|
||||
className='ph-no-capture'
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
|
||||
import { ToolIcon } from '../../shared/ToolIcon';
|
||||
|
||||
export function SuggestedToolsSection(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,7 +32,7 @@ export function SuggestedToolsSection(): React.ReactElement {
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
<IconComponent fontSize="small" />
|
||||
<ToolIcon icon={<IconComponent />} />
|
||||
<Text size="sm" fw={500}>
|
||||
{tool.title}
|
||||
</Text>
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface ToolFlowConfig {
|
||||
title?: TitleConfig;
|
||||
files: FilesStepConfig;
|
||||
steps: MiddleStepConfig[];
|
||||
// Optional preview content rendered between steps and the execute button
|
||||
preview?: React.ReactNode;
|
||||
executeButton?: ExecuteButtonConfig;
|
||||
review: ReviewStepConfig;
|
||||
forceStepNumbers?: boolean;
|
||||
@@ -90,6 +92,10 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
}, stepConfig.content)
|
||||
)}
|
||||
|
||||
{/* Preview (outside steps, above execute button).
|
||||
Hide when review is visible or when no files are selected. */}
|
||||
{!config.review.isVisible && (config.files.selectedFiles?.length ?? 0) > 0 && config.preview}
|
||||
|
||||
{/* Execute Button */}
|
||||
{config.executeButton && config.executeButton.isVisible !== false && (
|
||||
<OperationButton
|
||||
|
||||
@@ -5,13 +5,14 @@ import SubcategoryHeader from './SubcategoryHeader';
|
||||
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
|
||||
import { TFunction } from 'i18next';
|
||||
import { SubcategoryGroup } from '../../../hooks/useToolSections';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
export const renderToolButtons = (
|
||||
t: TFunction,
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
onSelect: (id: ToolId) => void,
|
||||
showSubcategoryHeader: boolean = true,
|
||||
disableNavigation: boolean = false,
|
||||
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
|
||||
@@ -32,7 +33,7 @@ export const renderToolButtons = (
|
||||
<div>
|
||||
{subcategory.tools.map(({ id, tool }) => {
|
||||
const matchedSynonym = matchedTextMap.get(id);
|
||||
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* SplitAutomationSettings - Used for automation only
|
||||
*
|
||||
* Combines split method selection and method-specific settings
|
||||
* into a single component for automation workflows.
|
||||
*/
|
||||
|
||||
import { Stack, Text, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SplitParameters } from "../../../hooks/tools/split/useSplitParameters";
|
||||
import { METHOD_OPTIONS, SplitMethod } from "../../../constants/splitConstants";
|
||||
import SplitSettings from "./SplitSettings";
|
||||
|
||||
interface SplitAutomationSettingsProps {
|
||||
parameters: SplitParameters;
|
||||
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SplitAutomationSettings = ({ parameters, onParameterChange, disabled = false }: SplitAutomationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Convert METHOD_OPTIONS to Select data format
|
||||
const methodSelectOptions = METHOD_OPTIONS.map((option) => {
|
||||
const prefix = t(option.prefixKey, "Split");
|
||||
const name = t(option.nameKey, "Method");
|
||||
return {
|
||||
value: option.value,
|
||||
label: `${prefix} ${name}`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Method Selection */}
|
||||
<Select
|
||||
label={t("split.steps.chooseMethod", "Choose Method")}
|
||||
placeholder={t("split.selectMethod", "Select a split method")}
|
||||
value={parameters.method}
|
||||
onChange={(value) => onParameterChange('method', value as (SplitMethod | '') || '')}
|
||||
data={methodSelectOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Method-Specific Settings */}
|
||||
{parameters.method && (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("split.steps.settings", "Settings")}
|
||||
</Text>
|
||||
<SplitSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitAutomationSettings;
|
||||
@@ -1,27 +1,35 @@
|
||||
import React from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
import { ToolIcon } from "../../shared/ToolIcon";
|
||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import { useToolNavigation } from "../../../hooks/useToolNavigation";
|
||||
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
|
||||
import FitText from "../../shared/FitText";
|
||||
import { useHotkeys } from "../../../contexts/HotkeyContext";
|
||||
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
|
||||
import { ToolId } from "src/types/toolId";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: string;
|
||||
id: ToolId;
|
||||
tool: ToolRegistryEntry;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSelect: (id: ToolId) => void;
|
||||
rounded?: boolean;
|
||||
disableNavigation?: boolean;
|
||||
matchedSynonym?: string;
|
||||
}
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
|
||||
const { t } = useTranslation();
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const { hotkeys } = useHotkeys();
|
||||
const binding = hotkeys[id];
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
const handleClick = (id: ToolId) => {
|
||||
if (isUnavailable) return;
|
||||
if (tool.link) {
|
||||
// Open external link in new tab
|
||||
@@ -37,11 +45,28 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
: tool.description;
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span>{tool.description}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
|
||||
{binding ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>{t('settings.hotkeys.shortcut', 'Shortcut')}</span>
|
||||
<HotkeyDisplay binding={binding} />
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500, fontStyle: 'italic' }}>{t('settings.hotkeys.noShortcut', 'No shortcut set')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
|
||||
<ToolIcon
|
||||
icon={tool.icon}
|
||||
opacity={isUnavailable ? 0.25 : 1}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
@@ -51,9 +76,9 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
{matchedSynonym && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
opacity: isUnavailable ? 0.25 : 1,
|
||||
marginTop: '1px',
|
||||
overflow: 'visible',
|
||||
@@ -82,7 +107,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
@@ -103,7 +128,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Center, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { useFileState } from "../../contexts/FileContext";
|
||||
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||
import { useViewer } from "../../contexts/ViewerContext";
|
||||
import { LocalEmbedPDF } from './LocalEmbedPDF';
|
||||
import { PdfViewerToolbar } from './PdfViewerToolbar';
|
||||
import { ThumbnailSidebar } from './ThumbnailSidebar';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useNavigationGuard, useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers';
|
||||
import NavigationWarningModal from '../shared/NavigationWarningModal';
|
||||
import { isStirlingFile } from '../../types/fileContext';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
@@ -29,22 +32,40 @@ const EmbedPdfViewerContent = ({
|
||||
const { colorScheme: _colorScheme } = useMantineColorScheme();
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState } = useViewer();
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const zoomState = getZoomState();
|
||||
const spreadState = getSpreadState();
|
||||
const rotationState = getRotationState();
|
||||
|
||||
// Check if we're in signature mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignatureMode = selectedTool === 'sign';
|
||||
// Track initial rotation to detect changes
|
||||
const initialRotationRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (initialRotationRef.current === null && rotationState.rotation !== undefined) {
|
||||
initialRotationRef.current = rotationState.rotation;
|
||||
}
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIds = activeFiles.map(f => f.fileId);
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
|
||||
// Check if we're in signature mode OR viewer annotation mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignatureMode = selectedTool === 'sign';
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
|
||||
// Determine which file to display
|
||||
const currentFile = React.useMemo(() => {
|
||||
@@ -131,6 +152,65 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [isViewerHovered]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkForChanges = () => {
|
||||
// Check for annotation changes via history
|
||||
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
|
||||
|
||||
console.log('[Viewer] Checking for unsaved changes:', {
|
||||
hasAnnotationChanges
|
||||
});
|
||||
return hasAnnotationChanges;
|
||||
};
|
||||
|
||||
console.log('[Viewer] Registering unsaved changes checker');
|
||||
registerUnsavedChangesChecker(checkForChanges);
|
||||
|
||||
return () => {
|
||||
console.log('[Viewer] Unregistering unsaved changes checker');
|
||||
unregisterUnsavedChangesChecker();
|
||||
};
|
||||
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
|
||||
// Apply changes - save annotations to new file version
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!currentFile || activeFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
console.log('[Viewer] Applying changes - exporting PDF with annotations');
|
||||
|
||||
// Step 1: Export PDF with annotations using EmbedPDF
|
||||
const arrayBuffer = await exportActions.saveAsCopy();
|
||||
if (!arrayBuffer) {
|
||||
throw new Error('Failed to export PDF');
|
||||
}
|
||||
|
||||
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
|
||||
|
||||
// Step 2: Convert ArrayBuffer to File
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const filename = currentFile.name || 'document.pdf';
|
||||
const file = new File([blob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
}
|
||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -184,9 +264,10 @@ 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}
|
||||
enableSignature={isSignatureMode}
|
||||
enableAnnotations={shouldEnableAnnotations}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
@@ -237,6 +318,15 @@ const EmbedPdfViewerContent = ({
|
||||
visible={isThumbnailSidebarVisible}
|
||||
onToggle={toggleThumbnailSidebar}
|
||||
/>
|
||||
|
||||
{/* Navigation Warning Modal */}
|
||||
{!previewFile && (
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user