Compare commits

...
Author SHA1 Message Date
Anthony Stirling 7eafe68e58 progress 2025-10-12 14:06:13 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
b54beaa66b 🌐 [V2] Sync Translations + Update README Progress Table (#4618)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation for the **V2 branch**. Below are the
details of the changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.json`) to reflect changes in the
reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-10 16:42:04 +01:00
e542f4bc61 legacy UI (#4612)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: EthanHealy01 <ethan.healy.21@gmail.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2025-10-10 15:36:35 +01:00
EthanHealy01andGitHub a339f71116 Overlay PDF tool (#4620)
# Description of Changes

- Added the OverlayPDF tool

---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-10-10 14:35:09 +01:00
b695e3900e Feature/v2/improve sign (#4627)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-09 13:35:42 +01:00
2158ee4db6 Feature/v2/googleDrive (#4592)
Google drive oss. Shouldn't have any effect on pr deployment. 
Mainly the removal of the old integration via backend.
I have added the picker service and lazy loading of the required google
dependency scripts when the necessary environment variables have been
implemented.

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-10-09 10:22:17 +01:00
James BruntonandGitHub 3090a85726 Assign shortcuts by default to only the quick access items (#4622)
# Description of Changes
Change shortcuts to just be a limited set for Quick Access tools rather
than for everything to avoid breaking browser key commands by default.

Also fixes a bunch of types of variables that were representing
`ToolId`s (I stopped at `automate` because there's loads in there so
I've just introduced some `any` casts for now 😭)
2025-10-08 17:18:05 +01:00
James BruntonandGitHub d714a1617f V2 File Editor Shortcuts (#4619)
# Description of Changes
Add shortcut icons for Pin and Download, and rename Delete to Close for
consistency with the rest of the tool.
2025-10-07 11:48:42 +01:00
James BruntonandGitHub 2a29bda34f Enable ESLint no-undef rule (#4346)
# Description of Changes
Enable ESLint [no-undef
rule](https://eslint.org/docs/latest/rules/no-undef)
2025-10-06 12:10:00 +00:00
ab6edd3196 Feature/v2/toggle_for_auto_unzip (#4584)
## default 
<img width="1012" height="627"
alt="{BF57458D-50A6-4057-94F1-D6AB4628EFD8}"
src="https://github.com/user-attachments/assets/85e550ab-0aed-4341-be95-d5d3bc7146db"
/>

## disabled
<img width="1141" height="620"
alt="{140DB87B-05CF-4E0E-A14A-ED15075BD2EE}"
src="https://github.com/user-attachments/assets/e0f56e84-fb9d-4787-b5cb-ba7c5a54b1e1"
/>

## unzip options
<img width="530" height="255"
alt="{482CE185-73D5-4D90-91BB-B9305C711391}"
src="https://github.com/user-attachments/assets/609b18ee-4eae-4cee-afc1-5db01f9d1088"
/>
<img width="579" height="473"
alt="{4DFCA96D-792D-4370-8C62-4BA42C9F1A5F}"
src="https://github.com/user-attachments/assets/c67fa4af-04ef-41df-9420-65ce4247e25b"
/>

## pop up and maintains version metadata
<img width="1071" height="1220"
alt="{7F2A785C-5717-4A79-9D45-74BDA46DF273}"
src="https://github.com/user-attachments/assets/9374cd2a-b7e5-46c4-a722-e141ab42f0de"
/>

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-06 11:29:38 +00:00
be7e79be55 fix(viewer): make initial zoom setup robust and clear timers in ZoomAPIBridge (#4588)
# Description of Changes

**What was changed**
- Reworked the initial zoom `useEffect`:
- Early return when `zoom` is unavailable or initial zoom was already
applied.
  - Extracted an `attemptInitialZoom` function with a single retry path.
- Introduced proper timeout handles (`timer`, `retryTimer`) and added a
cleanup function to clear them on unmount/re-render.
- Expanded the effect dependency array to include `zoomState` to avoid
stale state issues.
- Switched to nullish coalescing for safer defaulting of
`currentZoomLevel` (`zoomState.currentZoomLevel ?? 1.4`).
- Minor logging adjustments to clarify delayed/failed initialization
paths.

**Why the change was made**
- The previous implementation risked leaving dangling timers and could
re-attempt zoom unnecessarily, causing flicker or inconsistent initial
zoom when the viewport wasn’t ready.
- Including `zoomState` in dependencies ensures the component reacts to
state changes correctly and avoids stale reads.
- Cleanup of timers prevents memory leaks and race conditions during
rapid mounts/unmounts or navigation.

---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-10-06 12:24:12 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
c9e1b8eec5 🌐 [V2] Sync Translations + Update README Progress Table (#4578)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation for the **V2 branch**. Below are the
details of the changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.json`) to reflect changes in the
reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-04 10:53:58 +01:00
eba93a3b6c Frontend V2 Ui Tweaks (#4590)
* Top Controls only show when files > 0
* Moved content down so top controls don't obscure
* Viewer background set to match workbench and shadow around pages added
so that page boundaries are visible
* unsaved-changes modal rework

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-03 17:02:05 +01:00
160 changed files with 7448 additions and 1657 deletions
+33 -33
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![97%](https://geps.dev/progress/97) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![36%](https://geps.dev/progress/36) |
| Basque (Euskara) (eu_ES) | ![21%](https://geps.dev/progress/21) |
| Arabic (العربية) (ar_AR) | ![95%](https://geps.dev/progress/95) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![35%](https://geps.dev/progress/35) |
| Basque (Euskara) (eu_ES) | ![20%](https://geps.dev/progress/20) |
| Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) |
| Catalan (Català) (ca_CA) | ![38%](https://geps.dev/progress/38) |
| Croatian (Hrvatski) (hr_HR) | ![35%](https://geps.dev/progress/35) |
| Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) |
| Danish (Dansk) (da_DK) | ![35%](https://geps.dev/progress/35) |
| Catalan (Català) (ca_CA) | ![37%](https://geps.dev/progress/37) |
| Croatian (Hrvatski) (hr_HR) | ![34%](https://geps.dev/progress/34) |
| Czech (Česky) (cs_CZ) | ![38%](https://geps.dev/progress/38) |
| Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) |
| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![96%](https://geps.dev/progress/96) |
| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) |
| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) |
| Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) |
| Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![35%](https://geps.dev/progress/35) |
| Irish (Gaeilge) (ga_IE) | ![39%](https://geps.dev/progress/39) |
| Italian (Italiano) (it_IT) | ![97%](https://geps.dev/progress/97) |
| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) |
| Korean (한국어) (ko_KR) | ![39%](https://geps.dev/progress/39) |
| Norwegian (Norsk) (no_NB) | ![37%](https://geps.dev/progress/37) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![95%](https://geps.dev/progress/95) |
| Greek (Ελληνικά) (el_GR) | ![38%](https://geps.dev/progress/38) |
| Hindi (हिंदी) (hi_IN) | ![38%](https://geps.dev/progress/38) |
| Hungarian (Magyar) (hu_HU) | ![42%](https://geps.dev/progress/42) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![34%](https://geps.dev/progress/34) |
| Irish (Gaeilge) (ga_IE) | ![38%](https://geps.dev/progress/38) |
| Italian (Italiano) (it_IT) | ![95%](https://geps.dev/progress/95) |
| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![38%](https://geps.dev/progress/38) |
| Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) |
| Portuguese (Português) (pt_PT) | ![39%](https://geps.dev/progress/39) |
| Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) |
| Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) |
| Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![43%](https://geps.dev/progress/43) |
| Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) |
| Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) |
| Polish (Polski) (pl_PL) | ![40%](https://geps.dev/progress/40) |
| Portuguese (Português) (pt_PT) | ![38%](https://geps.dev/progress/38) |
| Portuguese Brazilian (Português) (pt_BR) | ![95%](https://geps.dev/progress/95) |
| Romanian (Română) (ro_RO) | ![32%](https://geps.dev/progress/32) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) |
| Simplified Chinese (简体中文) (zh_CN) | ![96%](https://geps.dev/progress/96) |
| Slovakian (Slovensky) (sk_SK) | ![28%](https://geps.dev/progress/28) |
| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) |
| Spanish (Español) (es_ES) | ![97%](https://geps.dev/progress/97) |
| Swedish (Svenska) (sv_SE) | ![38%](https://geps.dev/progress/38) |
| Thai (ไทย) (th_TH) | ![35%](https://geps.dev/progress/35) |
| Spanish (Español) (es_ES) | ![95%](https://geps.dev/progress/95) |
| Swedish (Svenska) (sv_SE) | ![37%](https://geps.dev/progress/37) |
| Thai (ไทย) (th_TH) | ![34%](https://geps.dev/progress/34) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) |
| Turkish (Türkçe) (tr_TR) | ![42%](https://geps.dev/progress/42) |
| Ukrainian (Українська) (uk_UA) | ![41%](https://geps.dev/progress/41) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![32%](https://geps.dev/progress/32) |
| Traditional Chinese (繁體中文) (zh_TW) | ![42%](https://geps.dev/progress/42) |
| Turkish (Türkçe) (tr_TR) | ![41%](https://geps.dev/progress/41) |
| Ukrainian (Українська) (uk_UA) | ![40%](https://geps.dev/progress/40) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![31%](https://geps.dev/progress/31) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -87,7 +87,8 @@ public class AutoJobAspect {
},
timeout,
queueable,
resourceWeight);
resourceWeight,
trackProgress);
} else {
// Use retry logic
return executeWithRetries(
@@ -220,7 +221,8 @@ public class AutoJobAspect {
},
timeout,
queueable,
resourceWeight);
resourceWeight,
trackProgress);
}
/**
@@ -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() {
@@ -0,0 +1,44 @@
package stirling.software.common.context;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* Holds contextual information for the currently executing job. Backed by a {@link ThreadLocal} so
* worker threads can retrieve the job ID and progress tracking preference while processing
* asynchronous work dispatched by {@link stirling.software.common.service.JobExecutorService
* JobExecutorService}.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class JobContextHolder {
private static final ThreadLocal<String> JOB_ID = new ThreadLocal<>();
private static final ThreadLocal<Boolean> PROGRESS_ENABLED = new ThreadLocal<>();
/** Store context for the current thread. */
public static void setContext(String jobId, boolean progressEnabled) {
if (jobId == null) {
clear();
return;
}
JOB_ID.set(jobId);
PROGRESS_ENABLED.set(progressEnabled);
}
/** Get the job ID bound to the current thread, or {@code null} if none. */
public static String getJobId() {
return JOB_ID.get();
}
/** Whether progress tracking is enabled for the current job (defaults to {@code false}). */
public static boolean isProgressEnabled() {
Boolean enabled = PROGRESS_ENABLED.get();
return enabled != null && enabled;
}
/** Remove all context associated with the current thread. */
public static void clear() {
JOB_ID.remove();
PROGRESS_ENABLED.remove();
}
}
@@ -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
@@ -41,6 +41,18 @@ public class JobResult {
/** The actual result object, if not a file */
private Object result;
/** Whether detailed progress tracking is enabled for this job. */
@Builder.Default private boolean trackProgress = true;
/** Most recent percentage update (0-100) if progress tracking is enabled. */
private Integer progressPercent;
/** Human readable progress message (e.g. current stage) when progress is enabled. */
private String progressMessage;
/** Timestamp of the last progress update. */
private LocalDateTime progressUpdatedAt;
/**
* Notes attached to this job for tracking purposes. Uses CopyOnWriteArrayList for thread safety
* when notes are added concurrently.
@@ -54,11 +66,28 @@ public class JobResult {
* @return A new JobResult
*/
public static JobResult createNew(String jobId) {
return JobResult.builder()
.jobId(jobId)
.complete(false)
.createdAt(LocalDateTime.now())
.build();
return createNew(jobId, true);
}
/**
* Create a new JobResult with the given job ID and progress tracking preference.
*
* @param jobId The job ID
* @param trackProgress Whether detailed progress should be tracked
* @return A new JobResult
*/
public static JobResult createNew(String jobId, boolean trackProgress) {
JobResult result =
JobResult.builder()
.jobId(jobId)
.complete(false)
.createdAt(LocalDateTime.now())
.trackProgress(trackProgress)
.build();
if (trackProgress) {
result.updateProgressInternal(0, "Pending");
}
return result;
}
/**
@@ -70,6 +99,9 @@ public class JobResult {
this.complete = true;
this.result = result;
this.completedAt = LocalDateTime.now();
if (trackProgress) {
updateProgressInternal(100, "Completed");
}
}
/**
@@ -81,6 +113,9 @@ public class JobResult {
this.complete = true;
this.error = error;
this.completedAt = LocalDateTime.now();
if (trackProgress) {
updateProgressInternal(100, error != null ? error : "Failed");
}
}
/**
@@ -92,6 +127,9 @@ public class JobResult {
this.complete = true;
this.resultFiles = new ArrayList<>(resultFiles);
this.completedAt = LocalDateTime.now();
if (trackProgress) {
updateProgressInternal(100, "Completed");
}
}
/**
@@ -161,4 +199,26 @@ public class JobResult {
public List<String> getNotes() {
return Collections.unmodifiableList(notes);
}
/**
* Update the progress information if tracking is enabled.
*
* @param percent The percent complete (0-100)
* @param message Optional descriptive message
*/
public void updateProgress(int percent, String message) {
if (!trackProgress) {
return;
}
updateProgressInternal(percent, message);
}
private void updateProgressInternal(int percent, String message) {
int clamped = Math.min(100, Math.max(0, percent));
this.progressPercent = clamped;
if (message != null && !message.isBlank()) {
this.progressMessage = message;
}
this.progressUpdatedAt = LocalDateTime.now();
}
}
@@ -20,6 +20,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.context.JobContextHolder;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.util.ExecutorFactory;
@@ -65,7 +66,7 @@ public class JobExecutorService {
* @return The response
*/
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1);
return runJobGeneric(async, work, -1, false, 50, true);
}
/**
@@ -78,7 +79,7 @@ public class JobExecutorService {
*/
public ResponseEntity<?> runJobGeneric(
boolean async, Supplier<Object> work, long customTimeoutMs) {
return runJobGeneric(async, work, customTimeoutMs, false, 50);
return runJobGeneric(async, work, customTimeoutMs, false, 50, true);
}
/**
@@ -96,7 +97,8 @@ public class JobExecutorService {
Supplier<Object> work,
long customTimeoutMs,
boolean queueable,
int resourceWeight) {
int resourceWeight,
boolean trackProgress) {
String jobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
@@ -138,6 +140,8 @@ public class JobExecutorService {
&& // Only async jobs can be queued
resourceMonitor.shouldQueueJob(resourceWeight);
boolean enableProgress = async && trackProgress;
if (shouldQueue) {
// Queue the job instead of executing immediately
log.debug(
@@ -145,13 +149,18 @@ public class JobExecutorService {
jobId,
resourceWeight);
taskManager.createTask(jobId);
taskManager.createTask(jobId, trackProgress);
Supplier<Object> contextualWork = withJobContext(jobId, enableProgress, work);
// Create a specialized wrapper that updates the TaskManager
Supplier<Object> wrappedWork =
() -> {
try {
Object result = work.get();
if (enableProgress) {
taskManager.updateProgress(jobId, 5, null);
}
Object result = contextualWork.get();
processJobResult(jobId, result);
return result;
} catch (Exception e) {
@@ -169,24 +178,49 @@ public class JobExecutorService {
// Return immediately with job ID
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId);
taskManager.createTask(jobId, trackProgress);
executor.execute(
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
() ->
runWithJobContext(
jobId,
enableProgress,
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms",
jobId,
timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
processJobResult(jobId, result);
} catch (TimeoutException te) {
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
taskManager.setError(jobId, "Job timed out");
} catch (Exception e) {
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
taskManager.setError(jobId, e.getMessage());
}
});
Supplier<Object> contextualWork =
withJobContext(jobId, enableProgress, work);
if (enableProgress) {
taskManager.updateProgress(jobId, 5, null);
}
// Execute with timeout
Object result =
executeWithTimeout(
contextualWork,
timeoutToUse,
jobId,
enableProgress);
processJobResult(jobId, result);
} catch (TimeoutException te) {
log.error(
"Job {} timed out after {} ms",
jobId,
timeoutToUse);
taskManager.setError(jobId, "Job timed out");
} catch (Exception e) {
log.error(
"Error executing job {}: {}",
jobId,
e.getMessage(),
e);
taskManager.setError(jobId, e.getMessage());
}
}));
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else {
@@ -194,7 +228,9 @@ public class JobExecutorService {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Execute with timeout
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
Supplier<Object> contextualWork = withJobContext(jobId, enableProgress, work);
Object result =
executeWithTimeout(contextualWork, timeoutToUse, jobId, enableProgress);
// If the result is already a ResponseEntity, return it directly
if (result instanceof ResponseEntity) {
@@ -452,12 +488,14 @@ public class JobExecutorService {
* @throws TimeoutException If the execution times out
* @throws Exception If the supplier throws an exception
*/
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
private <T> T executeWithTimeout(
Supplier<T> supplier, long timeoutMs, String jobId, boolean progressEnabled)
throws TimeoutException, Exception {
// Use the same executor as other async jobs for consistency
// This ensures all operations run on the same thread pool
java.util.concurrent.CompletableFuture<T> future =
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
java.util.concurrent.CompletableFuture.supplyAsync(
withJobContext(jobId, progressEnabled, supplier), executor);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
@@ -473,4 +511,62 @@ public class JobExecutorService {
throw new Exception("Execution was interrupted", e);
}
}
/** Backwards compatible helper used by tests via reflection. */
@SuppressWarnings("unused")
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
throws TimeoutException, Exception {
return executeWithTimeout(
supplier,
timeoutMs,
JobContextHolder.getJobId(),
JobContextHolder.isProgressEnabled());
}
private <T> Supplier<T> withJobContext(
String jobId, boolean progressEnabled, Supplier<T> delegate) {
if (jobId == null) {
return delegate;
}
return () -> {
String previousJobId = JobContextHolder.getJobId();
boolean previousProgress = JobContextHolder.isProgressEnabled();
JobContextHolder.setContext(jobId, progressEnabled);
try {
return delegate.get();
} finally {
if (previousJobId == null) {
JobContextHolder.clear();
} else {
JobContextHolder.setContext(previousJobId, previousProgress);
}
}
};
}
private void runWithJobContext(String jobId, boolean progressEnabled, Runnable runnable) {
if (jobId == null) {
runnable.run();
return;
}
Runnable contextualRunnable =
() -> {
String previousJobId = JobContextHolder.getJobId();
boolean previousProgress = JobContextHolder.isProgressEnabled();
JobContextHolder.setContext(jobId, progressEnabled);
try {
runnable.run();
} finally {
if (previousJobId == null) {
JobContextHolder.clear();
} else {
JobContextHolder.setContext(previousJobId, previousProgress);
}
}
};
contextualRunnable.run();
}
}
@@ -0,0 +1,56 @@
package stirling.software.common.service;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.context.JobContextHolder;
/**
* Convenience service that exposes a simple API for updating progress information from within job
* handlers executed through {@link JobExecutorService}. The service automatically ties progress
* updates to the current job (if any) and no-ops when progress tracking is disabled.
*/
@Service
@RequiredArgsConstructor
public class JobProgressService {
private final TaskManager taskManager;
/** Update the progress percentage for the current job. */
public boolean updateProgress(int percent, String message) {
String jobId = JobContextHolder.getJobId();
if (jobId == null || !JobContextHolder.isProgressEnabled()) {
return false;
}
return taskManager.updateProgress(jobId, percent, message);
}
/**
* Create a simple tracker that can be used to report progress across a fixed number of steps.
* When progress tracking is disabled for the current job, the returned tracker will be a
* lightweight no-op implementation.
*/
public JobProgressTracker tracker(int totalSteps) {
return tracker(totalSteps, null);
}
/**
* Create a tracker and optionally publish an initial message. Useful for multi-stage pipelines
* where the initial state should be visible to clients.
*/
public JobProgressTracker tracker(int totalSteps, String initialMessage) {
String jobId = JobContextHolder.getJobId();
boolean enabled = JobContextHolder.isProgressEnabled();
if (jobId == null || !enabled || totalSteps <= 0) {
return JobProgressTracker.disabled();
}
if (initialMessage != null && !initialMessage.isBlank()) {
taskManager.updateProgress(jobId, 0, initialMessage);
}
return new JobProgressTracker(taskManager, jobId, totalSteps, true);
}
}
@@ -0,0 +1,81 @@
package stirling.software.common.service;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
/**
* Utility that helps controllers report progress in a structured way by distributing the 0-100%
* range across a finite number of logical steps.
*/
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
public class JobProgressTracker {
private final TaskManager taskManager;
private final String jobId;
private final int totalSteps;
private final boolean enabled;
private int completedSteps;
static JobProgressTracker disabled() {
return new JobProgressTracker(null, null, 1, false);
}
/** Whether the tracker will emit updates. */
public boolean isEnabled() {
return enabled;
}
/** Advance the tracker by one step. */
public void advance() {
advanceBy(1, null);
}
/** Advance the tracker by {@code steps} steps. */
public void advanceBy(int steps, String message) {
if (!enabled) {
return;
}
int safeSteps = Math.max(0, steps);
completedSteps = Math.min(totalSteps, completedSteps + safeSteps);
publish(message);
}
/** Advance the tracker by {@code steps} steps without a message. */
public void advanceBy(int steps) {
advanceBy(steps, null);
}
/** Explicitly set the completed steps count. */
public void setStepsCompleted(int stepsCompleted, String message) {
if (!enabled) {
return;
}
completedSteps = Math.max(0, Math.min(totalSteps, stepsCompleted));
publish(message);
}
/** Explicitly set completed steps without a message. */
public void setStepsCompleted(int stepsCompleted) {
setStepsCompleted(stepsCompleted, null);
}
/** Mark the tracker as complete and emit a final message. */
public void complete(String message) {
if (!enabled) {
return;
}
completedSteps = totalSteps;
taskManager.updateProgress(jobId, 100, message);
}
/** Mark the tracker as complete without a message. */
public void complete() {
complete(null);
}
private void publish(String message) {
int percent = (int) Math.floor(((double) completedSteps / (double) totalSteps) * 100);
taskManager.updateProgress(jobId, percent, message);
}
}
@@ -65,7 +65,17 @@ public class TaskManager {
* @param jobId The job ID
*/
public void createTask(String jobId) {
jobResults.put(jobId, JobResult.createNew(jobId));
createTask(jobId, true);
}
/**
* Create a new task with the given job ID and progress tracking preference
*
* @param jobId The job ID
* @param trackProgress Whether detailed progress updates should be stored
*/
public void createTask(String jobId, boolean trackProgress) {
jobResults.put(jobId, JobResult.createNew(jobId, trackProgress));
log.debug("Created task with job ID: {}", jobId);
}
@@ -165,6 +175,11 @@ public class TaskManager {
&& jobResult.getError() == null) {
// If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully");
} else {
// Ensure progress is set to 100% with "Completed" message
if (jobResult.isTrackProgress()) {
jobResult.updateProgress(100, "Completed");
}
}
log.debug("Marked job ID: {} as complete", jobId);
}
@@ -209,6 +224,33 @@ public class TaskManager {
return false;
}
/**
* Update the progress information for a task.
*
* @param jobId The job ID
* @param percent Percentage complete (0-100)
* @param message Descriptive message for the current stage
* @return true if the progress update was accepted
*/
public boolean updateProgress(String jobId, int percent, String message) {
JobResult jobResult = jobResults.get(jobId);
if (jobResult == null) {
log.debug("Ignoring progress update for unknown job ID: {}", jobId);
return false;
}
if (!jobResult.isTrackProgress()) {
log.trace("Progress tracking disabled for job ID: {}", jobId);
return false;
}
jobResult.updateProgress(percent, message);
log.debug(
"Updated progress for job {} to {}% with message: {}",
jobId, jobResult.getProgressPercent(), message);
return true;
}
/**
* Get statistics about all jobs in the system
*
@@ -1,6 +1,7 @@
package stirling.software.common.annotations;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -72,6 +73,8 @@ class AutoJobPostMappingIntegrationTest {
@Captor private ArgumentCaptor<Integer> resourceWeightCaptor;
@Captor private ArgumentCaptor<Boolean> trackProgressCaptor;
@Test
void shouldExecuteWithCustomParameters() throws Throwable {
// Given
@@ -91,7 +94,12 @@ class AutoJobPostMappingIntegrationTest {
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
anyBoolean(),
any(Supplier.class),
anyLong(),
anyBoolean(),
anyInt(),
anyBoolean()))
.thenReturn(ResponseEntity.ok("success"));
// When
@@ -106,12 +114,14 @@ class AutoJobPostMappingIntegrationTest {
workCaptor.capture(),
timeoutCaptor.capture(),
queueableCaptor.capture(),
resourceWeightCaptor.capture());
resourceWeightCaptor.capture(),
trackProgressCaptor.capture());
assertTrue(asyncCaptor.getValue(), "Async should be true");
assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms");
assertTrue(queueableCaptor.getValue(), "Queueable should be true");
assertEquals(75, resourceWeightCaptor.getValue(), "Resource weight should be 75");
assertTrue(trackProgressCaptor.getValue(), "Track progress should propagate");
// Test that file was resolved
assertNotNull(pdfFile.getFileInput(), "File input should be set");
@@ -135,7 +145,12 @@ class AutoJobPostMappingIntegrationTest {
// Mock jobExecutorService to execute the work immediately
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
anyBoolean(),
any(Supplier.class),
anyLong(),
anyBoolean(),
anyInt(),
anyBoolean()))
.thenAnswer(
invocation -> {
Supplier<Object> work = invocation.getArgument(1);
@@ -150,6 +165,16 @@ class AutoJobPostMappingIntegrationTest {
// Verify that proceed was called twice (initial attempt + 1 retry)
verify(joinPoint, times(2)).proceed(any());
verify(jobExecutorService)
.runJobGeneric(
asyncCaptor.capture(),
workCaptor.capture(),
timeoutCaptor.capture(),
queueableCaptor.capture(),
resourceWeightCaptor.capture(),
trackProgressCaptor.capture());
assertFalse(trackProgressCaptor.getValue(), "Track progress should be false when disabled");
}
@Test
@@ -168,7 +193,12 @@ class AutoJobPostMappingIntegrationTest {
// Mock job executor to return a successful response
when(jobExecutorService.runJobGeneric(
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
anyBoolean(),
any(Supplier.class),
anyLong(),
anyBoolean(),
anyInt(),
anyBoolean()))
.thenReturn(ResponseEntity.ok("success"));
// When
@@ -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();
@@ -94,7 +94,7 @@ class JobExecutorServiceTest {
assertNotNull(jobResponse.getJobId());
// Verify task manager was called
verify(taskManager).createTask(jobIdCaptor.capture());
verify(taskManager).createTask(jobIdCaptor.capture(), eq(true));
}
@Test
@@ -129,7 +129,8 @@ class JobExecutorServiceTest {
when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future);
// When
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work, 5000, true, 80);
ResponseEntity<?> response =
jobExecutorService.runJobGeneric(true, work, 5000, true, 80, true);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
@@ -137,7 +138,7 @@ class JobExecutorServiceTest {
// Verify job was queued
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
verify(taskManager).createTask(anyString());
verify(taskManager).createTask(anyString(), eq(true));
}
@Test
@@ -0,0 +1,83 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import stirling.software.common.context.JobContextHolder;
class JobProgressServiceTest {
@Mock private TaskManager taskManager;
private JobProgressService jobProgressService;
private AutoCloseable mocks;
@BeforeEach
void setUp() {
mocks = MockitoAnnotations.openMocks(this);
jobProgressService = new JobProgressService(taskManager);
JobContextHolder.clear();
}
@AfterEach
void tearDown() throws Exception {
JobContextHolder.clear();
mocks.close();
}
@Test
void updateProgressReturnsFalseWhenNoContext() {
boolean updated = jobProgressService.updateProgress(10, "Stage");
assertFalse(updated);
verify(taskManager, never()).updateProgress(anyString(), anyInt(), anyString());
}
@Test
void updateProgressDelegatesToTaskManager() {
JobContextHolder.setContext("job-123", true);
when(taskManager.updateProgress("job-123", 20, "Processing")).thenReturn(true);
boolean updated = jobProgressService.updateProgress(20, "Processing");
assertTrue(updated);
verify(taskManager).updateProgress("job-123", 20, "Processing");
}
@Test
void trackerNoOpsWhenDisabled() {
JobContextHolder.setContext("job-123", false);
JobProgressTracker tracker = jobProgressService.tracker(5, "Start");
assertFalse(tracker.isEnabled());
tracker.advanceBy(1, "Step");
tracker.complete("Done");
verify(taskManager, never()).updateProgress(anyString(), anyInt(), anyString());
}
@Test
void trackerPublishesProgress() {
JobContextHolder.setContext("job-123", true);
JobProgressTracker tracker = jobProgressService.tracker(4, "Starting");
assertTrue(tracker.isEnabled());
verify(taskManager).updateProgress("job-123", 0, "Starting");
tracker.advanceBy(1, "25 percent");
tracker.advanceBy(1, "50 percent");
tracker.setStepsCompleted(3, "75 percent");
tracker.complete("Done");
verify(taskManager).updateProgress("job-123", 25, "25 percent");
verify(taskManager).updateProgress("job-123", 50, "50 percent");
verify(taskManager).updateProgress("job-123", 75, "75 percent");
verify(taskManager).updateProgress("job-123", 100, "Done");
}
}
@@ -50,6 +50,9 @@ class TaskManagerTest {
assertEquals(jobId, result.getJobId());
assertFalse(result.isComplete());
assertNotNull(result.getCreatedAt());
assertTrue(result.isTrackProgress());
assertEquals(0, result.getProgressPercent());
assertEquals("Pending", result.getProgressMessage());
}
@Test
@@ -68,6 +71,8 @@ class TaskManagerTest {
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
assertNotNull(result.getCompletedAt());
assertEquals(100, result.getProgressPercent());
assertEquals("Completed", result.getProgressMessage());
}
@Test
@@ -120,6 +125,8 @@ class TaskManagerTest {
assertTrue(result.isComplete());
assertEquals(errorMessage, result.getError());
assertNotNull(result.getCompletedAt());
assertEquals(100, result.getProgressPercent());
assertEquals(errorMessage, result.getProgressMessage());
}
@Test
@@ -138,6 +145,7 @@ class TaskManagerTest {
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
assertEquals(100, result.getProgressPercent());
}
@Test
@@ -154,6 +162,8 @@ class TaskManagerTest {
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals("Task completed successfully", result.getResult());
assertEquals(100, result.getProgressPercent());
assertEquals("Completed", result.getProgressMessage());
}
@Test
@@ -305,4 +315,45 @@ class TaskManagerTest {
// Assert
assertFalse(result);
}
@Test
void testCreateTaskWithoutProgressTracking() {
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId, false);
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertFalse(result.isTrackProgress());
assertNull(result.getProgressPercent());
assertNull(result.getProgressMessage());
}
@Test
void testUpdateProgress() {
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
boolean updated = taskManager.updateProgress(jobId, 50, "Halfway there");
assertTrue(updated);
JobResult result = taskManager.getJobResult(jobId);
assertEquals(50, result.getProgressPercent());
assertEquals("Halfway there", result.getProgressMessage());
assertNotNull(result.getProgressUpdatedAt());
}
@Test
void testUpdateProgressReturnsFalseWhenJobMissing() {
boolean updated = taskManager.updateProgress("missing", 10, "Stage");
assertFalse(updated);
}
@Test
void testUpdateProgressIgnoredWhenTrackingDisabled() {
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId, false);
boolean updated = taskManager.updateProgress(jobId, 75, "Stage");
assertFalse(updated);
}
}
@@ -22,12 +22,15 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.AnalysisApi;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
@AnalysisApi
@RequiredArgsConstructor
public class AnalysisController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/page-count", consumes = "multipart/form-data")
@JsonDataResponse
@@ -89,12 +92,22 @@ public class AnalysisController {
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
List<Map<String, Float>> dimensions = new ArrayList<>();
PDPageTree pages = document.getPages();
JobProgressTracker progressTracker =
jobProgressService.tracker(Math.max(1, pages.getCount()));
boolean trackProgress = progressTracker.isEnabled();
for (PDPage page : pages) {
Map<String, Float> pageDim = new HashMap<>();
pageDim.put("width", page.getBBox().getWidth());
pageDim.put("height", page.getBBox().getHeight());
dimensions.add(pageDim);
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
return dimensions;
}
@@ -134,13 +147,24 @@ public class AnalysisController {
Map<String, Object> annotInfo = new HashMap<>();
int totalAnnotations = 0;
Map<String, Integer> annotationTypes = new HashMap<>();
PDPageTree pages = document.getPages();
JobProgressTracker progressTracker =
jobProgressService.tracker(Math.max(1, pages.getCount()));
boolean trackProgress = progressTracker.isEnabled();
for (PDPage page : document.getPages()) {
for (PDPage page : pages) {
for (PDAnnotation annot : page.getAnnotations()) {
totalAnnotations++;
String subType = annot.getSubtype();
annotationTypes.merge(subType, 1, Integer::sum);
}
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
annotInfo.put("totalCount", totalAnnotations);
@@ -160,10 +184,22 @@ public class AnalysisController {
Map<String, Object> fontInfo = new HashMap<>();
Set<String> fontNames = new HashSet<>();
for (PDPage page : document.getPages()) {
PDPageTree pages = document.getPages();
JobProgressTracker progressTracker =
jobProgressService.tracker(Math.max(1, pages.getCount()));
boolean trackProgress = progressTracker.isEnabled();
for (PDPage page : pages) {
for (COSName font : page.getResources().getFontNames()) {
fontNames.add(font.getName());
}
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
fontInfo.put("fontCount", fontNames.size());
@@ -28,6 +28,8 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.WebResponseUtils;
@RestController
@@ -37,6 +39,7 @@ import stirling.software.common.util.WebResponseUtils;
public class BookletImpositionController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/booklet-imposition", consumes = "multipart/form-data")
@Operation(
@@ -68,21 +71,31 @@ public class BookletImpositionController {
PDDocument sourceDocument = pdfDocumentFactory.load(file);
int totalPages = sourceDocument.getNumberOfPages();
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, sides.size()));
boolean trackProgress = progressTracker.isEnabled();
// Create proper booklet with signature-based page ordering
PDDocument newDocument =
createSaddleBooklet(
sourceDocument,
totalPages,
addBorder,
spineLocation,
addGutter,
gutterSize,
doubleSided,
duplexPass,
flipOnShortEdge);
flipOnShortEdge,
sides,
progressTracker,
trackProgress);
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
@@ -154,14 +167,16 @@ public class BookletImpositionController {
private PDDocument createSaddleBooklet(
PDDocument src,
int totalPages,
boolean addBorder,
String spineLocation,
boolean addGutter,
float gutterSize,
boolean doubleSided,
String duplexPass,
boolean flipOnShortEdge)
boolean flipOnShortEdge,
List<Side> sides,
JobProgressTracker progressTracker,
boolean trackProgress)
throws IOException {
PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src);
@@ -176,8 +191,6 @@ public class BookletImpositionController {
if (gutterSize < 0) gutterSize = 0;
if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f;
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
for (Side side : sides) {
PDPage out = new PDPage(pageSize);
dst.addPage(out);
@@ -234,6 +247,10 @@ public class BookletImpositionController {
cellH,
addBorder);
}
if (trackProgress) {
progressTracker.advance();
}
}
return dst;
}
@@ -22,6 +22,8 @@ import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@@ -29,6 +31,7 @@ import stirling.software.common.util.WebResponseUtils;
public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/crop", consumes = "multipart/form-data")
@StandardPdfResponse
@@ -44,6 +47,8 @@ public class CropController {
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
int totalPages = sourceDocument.getNumberOfPages();
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, totalPages));
boolean trackProgress = progressTracker.isEnabled();
LayerUtility layerUtility = new LayerUtility(newDocument);
@@ -80,6 +85,10 @@ public class CropController {
request.getY(),
request.getWidth(),
request.getHeight()));
if (trackProgress) {
progressTracker.advance();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -87,6 +96,10 @@ public class CropController {
newDocument.close();
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
@@ -37,6 +37,8 @@ import stirling.software.SPDF.model.api.general.MergePdfsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.PdfErrorUtils;
@@ -48,6 +50,7 @@ import stirling.software.common.util.WebResponseUtils;
public class MergeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
// Merges a list of PDDocument objects into a single PDDocument
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
@@ -204,6 +207,10 @@ public class MergeController {
getSortComparator(
request.getSortType())); // Sort files based on the given sort type
JobProgressTracker progressTracker =
jobProgressService.tracker(Math.max(1, files.length) + 4);
boolean trackProgress = progressTracker.isEnabled();
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
List<Integer> invalidIndexes = new ArrayList<>();
@@ -224,6 +231,10 @@ public class MergeController {
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
if (trackProgress) {
progressTracker.advance();
}
}
if (!invalidIndexes.isEmpty()) {
@@ -243,6 +254,8 @@ public class MergeController {
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
errorFileIds.toString());
jobProgressService.updateProgress(100, null);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.body(payload.getBytes(StandardCharsets.UTF_8));
@@ -251,6 +264,10 @@ public class MergeController {
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
if (trackProgress) {
progressTracker.advance();
}
try {
mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(
@@ -289,10 +306,18 @@ public class MergeController {
addTableOfContents(mergedDocument, files);
}
if (trackProgress) {
progressTracker.advance();
}
// Save the modified document to a new ByteArrayOutputStream
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mergedDocument.save(baos);
if (trackProgress) {
progressTracker.complete();
}
String mergedFileName =
files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_merged_unsigned.pdf";
@@ -25,6 +25,8 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@@ -32,6 +34,7 @@ import stirling.software.common.util.WebResponseUtils;
public class MultiPageLayoutController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
@StandardPdfResponse
@@ -66,6 +69,8 @@ public class MultiPageLayoutController {
newDocument.addPage(newPage);
int totalPages = sourceDocument.getNumberOfPages();
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, totalPages));
boolean trackProgress = progressTracker.isEnabled();
float cellWidth = newPage.getMediaBox().getWidth() / cols;
float cellHeight = newPage.getMediaBox().getHeight() / rows;
@@ -126,6 +131,10 @@ public class MultiPageLayoutController {
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.stroke();
}
if (trackProgress) {
progressTracker.advance();
}
}
contentStream.close(); // Close the final content stream
@@ -135,6 +144,10 @@ public class MultiPageLayoutController {
newDocument.save(baos);
newDocument.close();
if (trackProgress) {
progressTracker.complete();
}
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
@@ -17,6 +17,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.WebResponseUtils;
/**
@@ -32,6 +34,8 @@ public class PdfImageRemovalController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
/**
* Endpoint to remove images from a PDF file.
*
@@ -54,8 +58,13 @@ public class PdfImageRemovalController {
// Load the PDF document
PDDocument document = pdfDocumentFactory.load(file);
int pageCount = Math.max(1, document.getNumberOfPages());
JobProgressTracker progressTracker = jobProgressService.tracker(pageCount + 1);
boolean trackProgress = progressTracker.isEnabled();
// Remove images from the PDF document using the service
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
PDDocument modifiedDocument =
pdfImageRemovalService.removeImagesFromPdf(document, progressTracker);
// Create a ByteArrayOutputStream to hold the modified PDF data
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
@@ -69,6 +78,10 @@ public class PdfImageRemovalController {
file.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_removed_images.pdf";
if (trackProgress) {
progressTracker.complete();
}
// Convert the byte array to a web response and return it
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
}
@@ -27,6 +27,8 @@ import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -35,6 +37,7 @@ import stirling.software.common.util.WebResponseUtils;
public class PdfOverlayController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
@StandardPdfResponse
@@ -63,13 +66,17 @@ public class PdfOverlayController {
try (PDDocument basePdf = pdfDocumentFactory.load(baseFile);
Overlay overlay = new Overlay()) {
JobProgressTracker progressTracker =
jobProgressService.tracker(basePdf.getNumberOfPages() + 1);
boolean trackProgress = progressTracker.isEnabled();
Map<Integer, String> overlayGuide =
prepareOverlayGuide(
basePdf.getNumberOfPages(),
overlayPdfFiles,
mode,
counts,
tempFiles);
tempFiles,
progressTracker);
overlay.setInputPDF(basePdf);
if (overlayPos == 0) {
@@ -80,6 +87,13 @@ public class PdfOverlayController {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
overlay.overlay(overlayGuide).save(outputStream);
if (trackProgress) {
progressTracker.advance();
progressTracker.complete();
} else {
jobProgressService.updateProgress(100, null);
}
byte[] data = outputStream.toByteArray();
String outputFilename =
Filenames.toSimpleFileName(baseFile.getOriginalFilename())
@@ -104,18 +118,25 @@ public class PdfOverlayController {
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
int basePageCount,
File[] overlayFiles,
String mode,
int[] counts,
List<File> tempFiles,
JobProgressTracker progressTracker)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
sequentialOverlay(
overlayGuide, overlayFiles, basePageCount, tempFiles, progressTracker);
break;
case "InterleavedOverlay":
interleavedOverlay(overlayGuide, overlayFiles, basePageCount);
interleavedOverlay(overlayGuide, overlayFiles, basePageCount, progressTracker);
break;
case "FixedRepeatOverlay":
fixedRepeatOverlay(overlayGuide, overlayFiles, counts, basePageCount);
fixedRepeatOverlay(
overlayGuide, overlayFiles, counts, basePageCount, progressTracker);
break;
default:
throw new IllegalArgumentException("Invalid overlay mode");
@@ -127,10 +148,12 @@ public class PdfOverlayController {
Map<Integer, String> overlayGuide,
File[] overlayFiles,
int basePageCount,
List<File> tempFiles)
List<File> tempFiles,
JobProgressTracker progressTracker)
throws IOException {
int overlayFileIndex = 0;
int pageCountInCurrentOverlay = 0;
boolean trackProgress = progressTracker.isEnabled();
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
if (pageCountInCurrentOverlay == 0
@@ -152,6 +175,10 @@ public class PdfOverlayController {
}
pageCountInCurrentOverlay++;
if (trackProgress) {
progressTracker.advance();
}
}
}
@@ -162,8 +189,12 @@ public class PdfOverlayController {
}
private void interleavedOverlay(
Map<Integer, String> overlayGuide, File[] overlayFiles, int basePageCount)
Map<Integer, String> overlayGuide,
File[] overlayFiles,
int basePageCount,
JobProgressTracker progressTracker)
throws IOException {
boolean trackProgress = progressTracker.isEnabled();
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
File overlayFile = overlayFiles[(basePageIndex - 1) % overlayFiles.length];
@@ -174,17 +205,26 @@ public class PdfOverlayController {
overlayGuide.put(basePageIndex, overlayFile.getAbsolutePath());
}
}
if (trackProgress) {
progressTracker.advance();
}
}
}
private void fixedRepeatOverlay(
Map<Integer, String> overlayGuide, File[] overlayFiles, int[] counts, int basePageCount)
Map<Integer, String> overlayGuide,
File[] overlayFiles,
int[] counts,
int basePageCount,
JobProgressTracker progressTracker)
throws IOException {
if (overlayFiles.length != counts.length) {
throw new IllegalArgumentException(
"Counts array length must match the number of overlay files");
}
int currentPage = 1;
boolean trackProgress = progressTracker.isEnabled();
for (int i = 0; i < overlayFiles.length; i++) {
File overlayFile = overlayFiles[i];
int repeatCount = counts[i];
@@ -196,6 +236,9 @@ public class PdfOverlayController {
for (int page = 0; page < overlayPageCount; page++) {
if (currentPage > basePageCount) break;
overlayGuide.put(currentPage++, overlayFile.getAbsolutePath());
if (trackProgress) {
progressTracker.advance();
}
}
}
}
@@ -24,6 +24,8 @@ import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -34,6 +36,7 @@ import stirling.software.common.util.WebResponseUtils;
public class RearrangePagesPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-pages")
@StandardPdfResponse
@@ -59,9 +62,20 @@ public class RearrangePagesPDFController {
Collections.sort(pagesToRemove);
JobProgressTracker progressTracker =
jobProgressService.tracker(Math.max(1, pagesToRemove.size()));
boolean trackProgress = progressTracker.isEnabled();
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
int pageIndex = pagesToRemove.get(i);
document.removePage(pageIndex);
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.pdfDocToWebResponse(
document,
@@ -272,14 +286,30 @@ public class RearrangePagesPDFController {
newPages.add(document.getPage(newPageOrder.get(i)));
}
int removalSteps = document.getNumberOfPages();
int additionSteps = newPages.size();
JobProgressTracker progressTracker =
jobProgressService.tracker(Math.max(1, removalSteps + additionSteps));
boolean trackProgress = progressTracker.isEnabled();
// Remove all the pages from the original document
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
document.removePage(i);
if (trackProgress) {
progressTracker.advance();
}
}
// Add the pages in the new order
for (PDPage page : newPages) {
document.addPage(page);
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.pdfDocToWebResponse(
@@ -19,6 +19,8 @@ import stirling.software.SPDF.model.api.general.RotatePDFRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -27,6 +29,7 @@ import stirling.software.common.util.WebResponseUtils;
public class RotationController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
@StandardPdfResponse
@@ -51,9 +54,19 @@ public class RotationController {
// Get the list of pages in the document
PDPageTree pages = document.getPages();
int totalPages = Math.max(1, pages.getCount());
JobProgressTracker progressTracker = jobProgressService.tracker(totalPages);
boolean trackProgress = progressTracker.isEnabled();
for (PDPage page : pages) {
page.setRotation(page.getRotation() + angle);
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.pdfDocToWebResponse(
@@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.general.ScalePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -34,6 +36,7 @@ import stirling.software.common.util.WebResponseUtils;
public class ScalePagesController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/scale-pages", consumes = "multipart/form-data")
@StandardPdfResponse
@@ -55,6 +58,8 @@ public class ScalePagesController {
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
int totalPages = sourceDocument.getNumberOfPages();
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, totalPages));
boolean trackProgress = progressTracker.isEnabled();
for (int i = 0; i < totalPages; i++) {
PDPage sourcePage = sourceDocument.getPage(i);
PDRectangle sourceSize = sourcePage.getMediaBox();
@@ -87,6 +92,10 @@ public class ScalePagesController {
contentStream.restoreGraphicsState();
contentStream.close();
if (trackProgress) {
progressTracker.advance();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -94,6 +103,10 @@ public class ScalePagesController {
outputDocument.close();
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
@@ -28,6 +28,8 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -37,6 +39,7 @@ import stirling.software.common.util.WebResponseUtils;
public class SplitPDFController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/split-pages")
@MultiFileResponse
@@ -77,6 +80,8 @@ public class SplitPDFController {
// split the document
splitDocumentsBoas = new ArrayList<>();
int previousPageNumber = 0;
JobProgressTracker progressTracker = jobProgressService.tracker(pageNumbers.size() + 2);
boolean trackProgress = progressTracker.isEnabled();
for (int splitPoint : pageNumbers) {
try (PDDocument splitDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
@@ -94,6 +99,10 @@ public class SplitPDFController {
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
if (trackProgress) {
progressTracker.advance();
}
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
@@ -128,11 +137,18 @@ public class SplitPDFController {
throw e;
}
if (trackProgress) {
progressTracker.advance();
}
log.debug("Successfully created zip file with split documents: {}", zipFile.toString());
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
// return the Resource in the response
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
@@ -33,6 +33,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.model.PdfMetadata;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.service.PdfMetadataService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -45,6 +47,7 @@ public class SplitPdfByChaptersController {
private final PdfMetadataService pdfMetadataService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
private static List<Bookmark> extractOutlineItems(
PDDocument sourceDocument,
@@ -178,8 +181,12 @@ public class SplitPdfByChaptersController {
bookmark.getStartPage(),
bookmark.getEndPage());
}
JobProgressTracker progressTracker = jobProgressService.tracker(bookmarks.size() + 1);
boolean trackProgress = progressTracker.isEnabled();
List<ByteArrayOutputStream> splitDocumentsBoas =
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
getSplitDocumentsBoas(
sourceDocument, bookmarks, includeMetadata, progressTracker);
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
@@ -190,6 +197,14 @@ public class SplitPdfByChaptersController {
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
sourceDocument.close();
if (trackProgress) {
progressTracker.advance();
progressTracker.complete();
} else {
jobProgressService.updateProgress(100, null);
}
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
} finally {
@@ -265,13 +280,17 @@ public class SplitPdfByChaptersController {
}
public List<ByteArrayOutputStream> getSplitDocumentsBoas(
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
PDDocument sourceDocument,
List<Bookmark> bookmarks,
boolean includeMetadata,
JobProgressTracker progressTracker)
throws Exception {
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
PdfMetadata metadata = null;
if (includeMetadata) {
metadata = pdfMetadataService.extractMetadataFromPdf(sourceDocument);
}
boolean trackProgress = progressTracker.isEnabled();
for (Bookmark bookmark : bookmarks) {
try (PDDocument splitDocument = new PDDocument()) {
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
@@ -291,6 +310,9 @@ public class SplitPdfByChaptersController {
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
if (trackProgress) {
progressTracker.advance();
}
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
@@ -32,6 +32,8 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@@ -39,6 +41,7 @@ import stirling.software.common.util.WebResponseUtils;
public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
@MultiFileResponse
@@ -59,15 +62,27 @@ public class SplitPdfBySectionsController {
int horiz = request.getHorizontalDivisions() + 1;
int verti = request.getVerticalDivisions() + 1;
boolean merge = Boolean.TRUE.equals(request.getMerge());
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz);
int totalSections = sourceDocument.getNumberOfPages() * horiz * verti;
JobProgressTracker progressTracker = jobProgressService.tracker(totalSections + 1);
boolean trackProgress = progressTracker.isEnabled();
List<PDDocument> splitDocuments =
splitPdfPages(sourceDocument, verti, horiz, progressTracker);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
if (merge) {
MergeController mergeController = new MergeController(pdfDocumentFactory);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mergeController.mergeDocuments(splitDocuments).save(baos);
if (trackProgress) {
progressTracker.advance();
}
mergeDocuments(splitDocuments).save(baos);
for (PDDocument doc : splitDocuments) {
doc.close();
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), filename + "_split.pdf");
}
for (PDDocument doc : splitDocuments) {
@@ -99,6 +114,10 @@ public class SplitPdfBySectionsController {
zipOut.finish();
data = Files.readAllBytes(zipFile);
if (trackProgress) {
progressTracker.advance();
progressTracker.complete();
}
return WebResponseUtils.bytesToWebResponse(
data, filename + "_split.zip", MediaType.APPLICATION_OCTET_STREAM);
@@ -107,11 +126,28 @@ public class SplitPdfBySectionsController {
}
}
private PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
PDDocument merged = pdfDocumentFactory.createNewDocument();
for (PDDocument doc : documents) {
for (PDPage page : doc.getPages()) {
merged.addPage(page);
}
}
return merged;
}
public List<PDDocument> splitPdfPages(
PDDocument document, int horizontalDivisions, int verticalDivisions)
PDDocument document,
int horizontalDivisions,
int verticalDivisions,
JobProgressTracker progressTracker)
throws IOException {
List<PDDocument> splitDocuments = new ArrayList<>();
int totalSections = document.getNumberOfPages() * horizontalDivisions * verticalDivisions;
int sectionCounter = 0;
boolean trackProgress = progressTracker.isEnabled();
for (PDPage originalPage : document.getPages()) {
PDRectangle originalMediaBox = originalPage.getMediaBox();
float width = originalMediaBox.getWidth();
@@ -151,6 +187,10 @@ public class SplitPdfBySectionsController {
}
splitDocuments.add(subDoc);
sectionCounter++;
if (trackProgress) {
progressTracker.advance();
}
}
}
}
@@ -25,6 +25,8 @@ import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -35,6 +37,7 @@ import stirling.software.common.util.WebResponseUtils;
public class SplitPdfBySizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
@MultiFileResponse
@@ -78,21 +81,28 @@ public class SplitPdfBySizeController {
String value = request.getSplitValue();
log.debug("Split type: {}, Split value: {}", type, value);
JobProgressTracker progressTracker =
jobProgressService.tracker(sourceDocument.getNumberOfPages() + 2);
boolean trackProgress = progressTracker.isEnabled();
if (type == 0) {
log.debug("Processing split by size");
long maxBytes = GeneralUtils.convertSizeToBytes(value);
log.debug("Max bytes per document: {}", maxBytes);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
handleSplitBySize(
sourceDocument, maxBytes, zipOut, filename, progressTracker);
} else if (type == 1) {
log.debug("Processing split by page count");
int pageCount = Integer.parseInt(value);
log.debug("Pages per document: {}", pageCount);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
handleSplitByPageCount(
sourceDocument, pageCount, zipOut, filename, progressTracker);
} else if (type == 2) {
log.debug("Processing split by document count");
int documentCount = Integer.parseInt(value);
log.debug("Total number of documents: {}", documentCount);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
handleSplitByDocCount(
sourceDocument, documentCount, zipOut, filename, progressTracker);
} else {
log.error("Invalid split type: {}", type);
throw ExceptionUtils.createIllegalArgumentException(
@@ -102,6 +112,13 @@ public class SplitPdfBySizeController {
}
log.debug("PDF splitting completed successfully");
if (trackProgress) {
progressTracker.advance();
progressTracker.complete();
} else {
jobProgressService.updateProgress(100, null);
}
} catch (Exception e) {
ExceptionUtils.logException("PDF document loading or processing", e);
throw e;
@@ -138,7 +155,11 @@ public class SplitPdfBySizeController {
}
private void handleSplitBySize(
PDDocument sourceDocument, long maxBytes, ZipOutputStream zipOut, String baseFilename)
PDDocument sourceDocument,
long maxBytes,
ZipOutputStream zipOut,
String baseFilename,
JobProgressTracker progressTracker)
throws IOException {
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
@@ -147,6 +168,7 @@ public class SplitPdfBySizeController {
int fileIndex = 1;
int totalPages = sourceDocument.getNumberOfPages();
int pageAdded = 0;
boolean trackProgress = progressTracker.isEnabled();
// Smart size check frequency - check more often with larger documents
int baseCheckFrequency = 5;
@@ -251,6 +273,9 @@ public class SplitPdfBySizeController {
}
}
}
if (trackProgress) {
progressTracker.setStepsCompleted(Math.min(totalPages, pageIndex + 1));
}
}
// Save final document if it has any pages
@@ -260,13 +285,20 @@ public class SplitPdfBySizeController {
currentDoc.getNumberOfPages(),
fileIndex);
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
if (trackProgress) {
progressTracker.setStepsCompleted(totalPages);
}
}
log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1);
}
private void handleSplitByPageCount(
PDDocument sourceDocument, int pageCount, ZipOutputStream zipOut, String baseFilename)
PDDocument sourceDocument,
int pageCount,
ZipOutputStream zipOut,
String baseFilename,
JobProgressTracker progressTracker)
throws IOException {
log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
int currentPageCount = 0;
@@ -284,12 +316,17 @@ public class SplitPdfBySizeController {
int pageIndex = 0;
int totalPages = sourceDocument.getNumberOfPages();
log.debug("Processing {} pages", totalPages);
boolean trackProgress = progressTracker.isEnabled();
try {
for (PDPage page : sourceDocument.getPages()) {
pageIndex++;
log.debug("Processing page {} of {}", pageIndex, totalPages);
if (trackProgress) {
progressTracker.setStepsCompleted(pageIndex);
}
try {
log.debug("Adding page {} to current document", pageIndex);
currentDoc.addPage(page);
@@ -347,6 +384,9 @@ public class SplitPdfBySizeController {
log.error("Error saving final document part {}", fileIndex - 1, e);
throw e;
}
if (trackProgress) {
progressTracker.setStepsCompleted(totalPages);
}
} else {
log.debug("Final document has no pages, skipping");
}
@@ -370,7 +410,8 @@ public class SplitPdfBySizeController {
PDDocument sourceDocument,
int documentCount,
ZipOutputStream zipOut,
String baseFilename)
String baseFilename,
JobProgressTracker progressTracker)
throws IOException {
log.debug("Starting handleSplitByDocCount with documentCount={}", documentCount);
int totalPageCount = sourceDocument.getNumberOfPages();
@@ -382,6 +423,7 @@ public class SplitPdfBySizeController {
int currentPageIndex = 0;
int fileIndex = 1;
boolean trackProgress = progressTracker.isEnabled();
for (int i = 0; i < documentCount; i++) {
log.debug("Creating document {} of {}", i + 1, documentCount);
@@ -407,6 +449,9 @@ public class SplitPdfBySizeController {
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
currentPageIndex++;
if (trackProgress) {
progressTracker.setStepsCompleted(currentPageIndex);
}
} catch (Exception e) {
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
throw ExceptionUtils.createFileProcessingException("split", e);
@@ -22,6 +22,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.WebResponseUtils;
@GeneralApi
@@ -29,6 +31,7 @@ import stirling.software.common.util.WebResponseUtils;
public class ToSinglePageController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
@StandardPdfResponse
@@ -45,6 +48,10 @@ public class ToSinglePageController {
// Load the source document
PDDocument sourceDocument = pdfDocumentFactory.load(request);
int totalPages = Math.max(1, sourceDocument.getNumberOfPages());
JobProgressTracker progressTracker = jobProgressService.tracker(totalPages + 1);
boolean trackProgress = progressTracker.isEnabled();
// Calculate total height and max width
float totalHeight = 0;
float maxWidth = 0;
@@ -79,6 +86,9 @@ public class ToSinglePageController {
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
yOffset -= page.getMediaBox().getHeight();
pageIndex++;
if (trackProgress) {
progressTracker.advance();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -86,6 +96,10 @@ public class ToSinglePageController {
newDocument.close();
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
@@ -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",
@@ -35,6 +35,8 @@ import stirling.software.SPDF.model.api.security.SanitizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.WebResponseUtils;
@SecurityApi
@@ -42,6 +44,7 @@ import stirling.software.common.util.WebResponseUtils;
public class SanitizeController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
@StandardPdfResponse
@@ -61,28 +64,54 @@ public class SanitizeController {
boolean removeFonts = Boolean.TRUE.equals(request.getRemoveFonts());
PDDocument document = pdfDocumentFactory.load(inputFile, true);
int pageCount = Math.max(1, document.getNumberOfPages());
int pageSteps = 0;
if (removeJavaScript) pageSteps += pageCount;
if (removeEmbeddedFiles) pageSteps += pageCount;
if (removeLinks) pageSteps += pageCount;
if (removeFonts) pageSteps += pageCount;
int metadataSteps = 0;
if (removeXMPMetadata) metadataSteps++;
if (removeMetadata) metadataSteps++;
int totalSteps = Math.max(1, pageSteps + metadataSteps);
JobProgressTracker progressTracker = jobProgressService.tracker(totalSteps);
boolean trackProgress = progressTracker.isEnabled();
if (removeJavaScript) {
sanitizeJavaScript(document);
sanitizeJavaScript(document, progressTracker, trackProgress);
}
if (removeEmbeddedFiles) {
sanitizeEmbeddedFiles(document);
sanitizeEmbeddedFiles(document, progressTracker, trackProgress);
}
if (removeXMPMetadata) {
sanitizeXMPMetadata(document);
if (trackProgress) {
progressTracker.advance();
}
}
if (removeMetadata) {
sanitizeDocumentInfoMetadata(document);
if (trackProgress) {
progressTracker.advance();
}
}
if (removeLinks) {
sanitizeLinks(document);
sanitizeLinks(document, progressTracker, trackProgress);
}
if (removeFonts) {
sanitizeFonts(document);
sanitizeFonts(document, progressTracker, trackProgress);
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.pdfDocToWebResponse(
@@ -92,7 +121,9 @@ public class SanitizeController {
+ "_sanitized.pdf");
}
private void sanitizeJavaScript(PDDocument document) throws IOException {
private void sanitizeJavaScript(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress)
throws IOException {
// Get the root dictionary (catalog) of the PDF
PDDocumentCatalog catalog = document.getDocumentCatalog();
@@ -140,10 +171,15 @@ public class SanitizeController {
}
}
}
if (trackProgress) {
progressTracker.advance();
}
}
}
private void sanitizeEmbeddedFiles(PDDocument document) {
private void sanitizeEmbeddedFiles(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress) {
PDPageTree allPages = document.getPages();
for (PDPage page : allPages) {
@@ -151,6 +187,9 @@ public class SanitizeController {
if (res != null && res.getCOSObject() != null) {
res.getCOSObject().removeItem(COSName.getPDFName("EmbeddedFiles"));
}
if (trackProgress) {
progressTracker.advance();
}
}
}
@@ -171,7 +210,9 @@ public class SanitizeController {
}
}
private void sanitizeLinks(PDDocument document) throws IOException {
private void sanitizeLinks(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress)
throws IOException {
for (PDPage page : document.getPages()) {
for (PDAnnotation annotation : page.getAnnotations()) {
if (annotation != null && annotation instanceof PDAnnotationLink linkAnnotation) {
@@ -183,16 +224,23 @@ public class SanitizeController {
}
}
}
if (trackProgress) {
progressTracker.advance();
}
}
}
private void sanitizeFonts(PDDocument document) {
private void sanitizeFonts(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress) {
for (PDPage page : document.getPages()) {
if (page != null
&& page.getResources() != null
&& page.getResources().getCOSObject() != null) {
page.getResources().getCOSObject().removeItem(COSName.getPDFName("Font"));
}
if (trackProgress) {
progressTracker.advance();
}
}
}
}
@@ -40,6 +40,8 @@ import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -48,6 +50,7 @@ import stirling.software.common.util.WebResponseUtils;
public class WatermarkController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@InitBinder
public void initBinder(WebDataBinder binder) {
@@ -100,6 +103,10 @@ public class WatermarkController {
PDDocument document = pdfDocumentFactory.load(pdfFile);
// Create a page in the document
int totalPages = Math.max(1, document.getNumberOfPages());
JobProgressTracker progressTracker = jobProgressService.tracker(totalPages);
boolean trackProgress = progressTracker.isEnabled();
for (PDPage page : document.getPages()) {
// Get the page's content stream
@@ -138,6 +145,10 @@ public class WatermarkController {
// Close the content stream
contentStream.close();
if (trackProgress) {
progressTracker.advance();
}
}
if (convertPdfToImage) {
@@ -146,6 +157,10 @@ public class WatermarkController {
document = convertedPdf;
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
@@ -11,6 +11,8 @@ import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.springframework.stereotype.Service;
import stirling.software.common.service.JobProgressTracker;
/** Service class responsible for removing image objects from a PDF document. */
@Service
public class PdfImageRemovalService {
@@ -26,6 +28,13 @@ public class PdfImageRemovalService {
* @throws IOException If an error occurs while processing the PDF document.
*/
public PDDocument removeImagesFromPdf(PDDocument document) throws IOException {
return removeImagesFromPdf(document, null);
}
public PDDocument removeImagesFromPdf(PDDocument document, JobProgressTracker progressTracker)
throws IOException {
boolean trackProgress = progressTracker != null && progressTracker.isEnabled();
// Iterate over each page in the PDF document
for (PDPage page : document.getPages()) {
PDResources resources = page.getResources();
@@ -45,6 +54,10 @@ public class PdfImageRemovalService {
for (COSName name : namesToRemove) {
resources.put(name, (PDXObject) null);
}
if (trackProgress) {
progressTracker.advance();
}
}
return document;
}
@@ -1946,6 +1946,25 @@ viewer.zoomIn=Zoom in
# Tool Picker
toolPicker.searchPlaceholder=Search tools...
toolPicker.noToolsFound=No tools found
toolPanel.toggle.legacy=Switch to legacy mode
toolPanel.toggle.sidebar=Switch to sidebar mode
toolPanel.placeholder=Choose a tool to get started
toolPanel.legacy.heading=All tools (legacy view)
toolPanel.legacy.tagline=Browse and launch tools while keeping the classic full-width gallery.
toolPanel.legacy.descriptionsOn=Showing descriptions
toolPanel.legacy.descriptionsOff=Descriptions hidden
toolPanel.legacy.noResults=Try adjusting your search or toggle descriptions to find what you need.
toolPanel.legacy.matchedSynonym=Matches "{{text}}"
toolPanel.modePrompt.title=Choose how you browse tools
toolPanel.modePrompt.description=Preview both layouts and decide how you want to explore Stirling PDF tools.
toolPanel.modePrompt.sidebarTitle=Advanced sidebar
toolPanel.modePrompt.sidebarDescription=Keep tools alongside your workspace for quick switching.
toolPanel.modePrompt.recommended=Recommended
toolPanel.modePrompt.chooseSidebar=Use advanced sidebar
toolPanel.modePrompt.legacyTitle=Legacy fullscreen
toolPanel.modePrompt.legacyDescription=Browse every tool in a catalogue that covers the workspace until you pick one.
toolPanel.modePrompt.chooseLegacy=Use legacy fullscreen
toolPanel.modePrompt.dismiss=Maybe later
pageEditor.reset=Reset Changes
pageEditor.zoomIn=Zoom In
pageEditor.zoomOut=Zoom Out
@@ -1958,3 +1977,39 @@ viewer.nextPage=Next Page
viewer.pageNavigation=Page Navigation
viewer.currentPage=Current Page
viewer.totalPages=Total Pages
toolPanel.legacy.favorites=Favourites
toolPanel.legacy.recent=Recently used
toolPanel.legacy.favorite=Add to favourites
toolPanel.legacy.unfavorite=Remove from favourites
toolPanel.legacy.settings.title=Customise appearance
toolPanel.legacy.settings.iconBackground.label=Tool icon background
toolPanel.legacy.settings.iconBackground.description=When to show coloured backgrounds behind tool icons
toolPanel.legacy.settings.iconBackground.none=None
toolPanel.legacy.settings.iconBackground.hover=On hover
toolPanel.legacy.settings.iconBackground.always=Always
toolPanel.legacy.settings.iconColor.label=Tool icon colour
toolPanel.legacy.settings.iconColor.description=Colour scheme for tool icons
toolPanel.legacy.settings.iconColor.colored=Coloured
toolPanel.legacy.settings.iconColor.vibrant=Vibrant
toolPanel.legacy.settings.iconColor.monochrome=Monochrome
toolPanel.legacy.settings.sectionTitle.label=Section titles
toolPanel.legacy.settings.sectionTitle.description=Colour for category section titles
toolPanel.legacy.settings.sectionTitle.colored=Coloured
toolPanel.legacy.settings.sectionTitle.neutral=Neutral
toolPanel.legacy.settings.headerIcon.label=Section header icons
toolPanel.legacy.settings.headerIcon.description=Colour for Favourites/Recent icons
toolPanel.legacy.settings.headerIcon.colored=Coloured
toolPanel.legacy.settings.headerIcon.monochrome=Monochrome
toolPanel.legacy.settings.headerBadge.label=Section header badges
toolPanel.legacy.settings.headerBadge.description=Colour for count badges in section headers
toolPanel.legacy.settings.headerBadge.colored=Coloured
toolPanel.legacy.settings.headerBadge.neutral=Neutral
toolPanel.legacy.settings.border.label=Tool item borders
toolPanel.legacy.settings.border.description=Show borders around tool items
toolPanel.legacy.settings.border.visible=Visible
toolPanel.legacy.settings.border.hidden=Hidden
toolPanel.legacy.settings.hover.label=Hover effect intensity
toolPanel.legacy.settings.hover.description=How prominent the hover effect should be
toolPanel.legacy.settings.hover.subtle=Subtle
toolPanel.legacy.settings.hover.moderate=Moderate
toolPanel.legacy.settings.hover.prominent=Prominent
@@ -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,17 +14,19 @@ import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
class RearrangePagesPDFControllerTest {
@Mock private CustomPDFDocumentFactory mockPdfDocumentFactory;
@Mock private JobProgressService mockJobProgressService;
private RearrangePagesPDFController sut;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
sut = new RearrangePagesPDFController(mockPdfDocumentFactory);
sut = new RearrangePagesPDFController(mockPdfDocumentFactory, mockJobProgressService);
}
/** Tests the behavior of the oddEvenMerge method when there are no pages in the document. */
@@ -22,11 +22,14 @@ import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.general.RotatePDFRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.JobProgressService;
import stirling.software.common.service.JobProgressTracker;
@ExtendWith(MockitoExtension.class)
public class RotationControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private JobProgressService jobProgressService;
@InjectMocks private RotationController rotationController;
@@ -42,12 +45,16 @@ public class RotationControllerTest {
PDDocument mockDocument = mock(PDDocument.class);
PDPageTree mockPages = mock(PDPageTree.class);
PDPage mockPage = mock(PDPage.class);
JobProgressTracker mockTracker = mock(JobProgressTracker.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(mockDocument.getPages()).thenReturn(mockPages);
when(mockPages.getCount()).thenReturn(1);
when(mockPages.iterator())
.thenReturn(java.util.Collections.singletonList(mockPage).iterator());
when(mockPage.getRotation()).thenReturn(0);
when(jobProgressService.tracker(1)).thenReturn(mockTracker);
when(mockTracker.isEnabled()).thenReturn(false);
// Act
ResponseEntity<byte[]> response = rotationController.rotatePDF(request);
@@ -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) {
+8 -1
View File
@@ -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
+3
View File
@@ -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:
+3
View File
@@ -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:
+28 -2
View File
@@ -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,
}
}
},
);
+98 -4
View File
@@ -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",
+6
View File
@@ -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",
+87 -13
View File
@@ -1,9 +1,27 @@
{
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
"toolPanel": {
"modePrompt": {
"title": "Choose how you browse tools",
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
"sidebarTitle": "Sidebar mode",
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
"recommended": "Recommended",
"chooseSidebar": "Use sidebar mode",
"fullscreenTitle": "Fullscreen mode - (legacy)",
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
"chooseFullscreen": "Use fullscreen mode",
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
}
},
"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 +83,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",
@@ -256,6 +276,16 @@
"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.",
@@ -270,7 +300,8 @@
"capturing": "Press keys… (Esc to cancel)",
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut"
"shortcut": "Shortcut",
"noShortcut": "No shortcut set"
}
},
"changeCreds": {
@@ -584,11 +615,6 @@
"title": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlayPdfs": {
"tags": "overlay,combine,stack",
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"splitBySections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
@@ -1805,8 +1831,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": {
@@ -2528,11 +2562,15 @@
"overlay-pdfs": {
"tags": "Overlay",
"header": "Overlay PDF Files",
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files"
"label": "Select Overlay PDF Files",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
},
"mode": {
"label": "Select Overlay Mode",
@@ -2542,14 +2580,49 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"submit": "Submit"
"submit": "Submit",
"settings": {
"title": "Settings"
},
"results": {
"title": "Overlay Results"
},
"tooltip": {
"header": {
"title": "Overlay PDFs Overview"
},
"description": {
"title": "Description",
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
},
"mode": {
"title": "Overlay Mode",
"text": "Choose how to distribute overlay pages across the base PDF pages.",
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
},
"position": {
"title": "Overlay Position",
"text": "Foreground places the overlay on top of the page. Background places it behind."
},
"overlayFiles": {
"title": "Overlay Files",
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
},
"counts": {
"title": "Counts (Fixed Repeat only)",
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
}
}
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize,Customise",
@@ -3186,6 +3259,7 @@
"lastModified": "Last Modified",
"toolChain": "Tools Applied",
"restore": "Restore",
"unzip": "Unzip",
"searchFiles": "Search files...",
"recent": "Recent",
"localFiles": "Local Files",
+59 -3
View File
@@ -2,6 +2,23 @@
"language": {
"direction": "ltr"
},
"toolPanel": {
"modePrompt": {
"title": "Choose how you browse tools",
"description": "Preview both layouts and decide how you want to explore Stirling PDF tools.",
"sidebarTitle": "Sidebar mode",
"sidebarDescription": "Keep tools alongside your workspace for quick switching.",
"recommended": "Recommended",
"chooseSidebar": "Use sidebar mode",
"fullscreenTitle": "Fullscreen mode - (legacy)",
"fullscreenDescription": "Browse every tool in a catalogue that covers the workspace until you pick one.",
"chooseFullscreen": "Use fullscreen mode",
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
}
},
"addPageNumbers": {
"fontSize": "Font Size",
"fontName": "Font Name",
@@ -1521,11 +1538,15 @@
"overlay-pdfs": {
"tags": "Overlay",
"header": "Overlay PDF Files",
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files"
"label": "Select Overlay PDF Files",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
},
"mode": {
"label": "Select Overlay Mode",
@@ -1535,14 +1556,49 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"submit": "Submit"
"submit": "Submit",
"settings": {
"title": "Settings"
},
"results": {
"title": "Overlay Results"
},
"tooltip": {
"header": {
"title": "Overlay PDFs Overview"
},
"description": {
"title": "Description",
"text": "Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background."
},
"mode": {
"title": "Overlay Mode",
"text": "Choose how to distribute overlay pages across the base PDF pages.",
"sequential": "Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.",
"interleaved": "Interleaved Overlay: Take one page from each overlay in turn.",
"fixedRepeat": "Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers."
},
"position": {
"title": "Overlay Position",
"text": "Foreground places the overlay on top of the page. Background places it behind."
},
"overlayFiles": {
"title": "Overlay Files",
"text": "Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes."
},
"counts": {
"title": "Counts (Fixed Repeat only)",
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
}
}
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize",
+22 -19
View File
@@ -6,6 +6,7 @@ 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";
@@ -41,25 +42,27 @@ export default function App() {
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<ErrorBoundary>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
<PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</PreferencesProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>
+28
View File
@@ -9,6 +9,9 @@ import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex';
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
import { loadScript } from '../utils/scriptLoader';
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -84,6 +87,30 @@ 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';
@@ -100,6 +127,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
styles={{
content: {
position: 'relative',
@@ -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>
);
};
@@ -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;
@@ -384,4 +398,102 @@
.addFileSubtext {
font-size: 0.875rem;
opacity: 0.8;
}
}
/* -----------------------
Async Job Progress HUD
----------------------- */
.jobProgressContainer {
position: absolute;
left: 8px;
right: 8px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 6;
pointer-events: none;
}
.jobProgressRow {
pointer-events: auto;
border-radius: 12px;
padding: 10px 14px 12px;
background: rgba(18, 26, 44, 0.88);
box-shadow: 0 18px 36px rgba(11, 16, 28, 0.25);
backdrop-filter: blur(12px);
color: #f1f6ff;
transition: transform 120ms ease;
}
.jobProgressRow[data-status='failed'] {
background: rgba(128, 28, 38, 0.9);
}
:global([data-mantine-color-scheme='dark']) .jobProgressRow {
background: rgba(28, 39, 58, 0.9);
color: var(--mantine-color-gray-2);
}
:global([data-mantine-color-scheme='dark']) .jobProgressRow[data-status='failed'] {
background: rgba(138, 34, 43, 0.92);
}
.jobProgressHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.jobProgressLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.jobProgressValue {
font-variant-numeric: tabular-nums;
opacity: 0.92;
}
.jobProgressTrack {
position: relative;
margin-top: 8px;
height: 6px;
border-radius: 999px;
background: rgba(241, 246, 255, 0.25);
overflow: hidden;
}
.jobProgressRow[data-status='failed'] .jobProgressTrack {
background: rgba(255, 255, 255, 0.23);
}
.jobProgressFill {
position: absolute;
inset: 0;
width: 0;
height: 100%;
background: linear-gradient(90deg, #5aa5ff 0%, #206de5 100%);
transition: width 160ms ease;
}
.jobProgressRow[data-status='queued'] .jobProgressFill {
background: linear-gradient(90deg, #cfd8dc 0%, #a7b6bf 100%);
}
.jobProgressRow[data-status='failed'] .jobProgressFill {
background: linear-gradient(90deg, #ff8a80 0%, #e53935 100%);
}
.jobProgressError {
margin-top: 8px;
font-size: 0.72rem;
line-height: 1.3;
opacity: 0.92;
}
@@ -1,9 +1,9 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
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';
@@ -37,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()]);
@@ -61,7 +62,7 @@ const FileEditor = ({
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
React.useEffect(() => {
useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
@@ -287,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) {
@@ -309,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) {
@@ -348,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 ? (
@@ -424,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)}
/>
@@ -446,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,16 +44,20 @@ const FileEditorThumbnail = ({
index,
selectedFiles,
onToggleFile,
onDeleteFile,
onCloseFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
const activeJobs = useMemo(() => file.activeJobs ?? [], [file.activeJobs]);
const visibleJobs = useMemo(() => activeJobs.filter(job => job.status !== 'completed'), [activeJobs]);
const hasActiveJobs = visibleJobs.length > 0;
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
@@ -64,6 +71,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);
@@ -206,6 +216,9 @@ const FileEditorThumbnail = ({
};
const progressContainerBottom = file.toolHistory ? '56px' : '12px';
const toolChainBottom = hasActiveJobs ? '8px' : '4px';
return (
<div
ref={fileElementRef}
@@ -251,18 +264,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 +342,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 +354,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>
)}
@@ -377,11 +442,52 @@ const FileEditorThumbnail = ({
)}
</div>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
{hasActiveJobs && (
<div
className={styles.jobProgressContainer}
style={{ bottom: progressContainerBottom }}
>
{visibleJobs.map(job => {
const queueSuffix = typeof job.queuePosition === 'number' ? ` (#${job.queuePosition + 1})` : '';
const label = job.message
|| (job.status === 'failed'
? t('async.jobFailed', 'Job failed')
: job.status === 'queued'
? `${t('async.jobQueued', 'Waiting in queue')}${queueSuffix}`
: t('async.jobProcessing', 'Processing…'));
const rawPercent = Number.isFinite(job.progressPercent) ? Math.round(job.progressPercent) : 0;
const percent = Math.max(0, Math.min(rawPercent, 100));
const displayValue = job.status === 'failed'
? t('async.jobFailedShort', 'Failed')
: job.status === 'queued'
? t('async.jobQueuedShort', 'Queued')
: `${percent}%`;
const fillBase = job.status === 'failed'
? 100
: Math.max(job.status === 'queued' ? 12 : 6, percent);
const fillWidth = Math.max(0, Math.min(fillBase, 100));
return (
<div
key={job.jobId}
className={styles.jobProgressRow}
data-status={job.status}
title={job.error || label}
>
<div className={styles.jobProgressHeader}>
<span className={styles.jobProgressLabel}>{label}</span>
<span className={styles.jobProgressValue}>{displayValue}</span>
</div>
<div className={styles.jobProgressTrack}>
<div className={styles.jobProgressFill} style={{ width: `${fillWidth}%` }} />
</div>
{job.status === 'failed' && job.error && (
<div className={styles.jobProgressError}>{job.error}</div>
)}
</div>
);
})}
</div>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
@@ -393,7 +499,7 @@ const FileEditorThumbnail = ({
{file.toolHistory && (
<div style={{
position: 'absolute',
bottom: '4px',
bottom: toolChainBottom,
left: '4px',
right: '4px',
padding: '4px 6px',
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
@@ -50,7 +50,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
};
// Reset index when selection changes
React.useEffect(() => {
useEffect(() => {
if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0);
}
@@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
</Group>
<Box ml="md">
{sortedHistory.map((historyFile, _index) => (
{sortedHistory.map((historyFile) => (
<FileListItem
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
@@ -56,7 +56,6 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
onDoubleClick={() => onFileDoubleClick(historyFile)}
isHistoryFile={true} // This enables "Add to Recents" in menu
isLatestVersion={false} // History files are never latest
// onAddToRecents is accessed from context by FileListItem
/>
))}
</Box>
@@ -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, 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;
@@ -183,7 +188,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onAddToRecents(file);
}}
>
{t('fileManager.restore', 'Restore')}
@@ -192,6 +196,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>
@@ -55,4 +55,4 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 's
);
};
export default HotkeyDisplay;
export default HotkeyDisplay;
+7 -4
View File
@@ -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()}
@@ -662,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 && (
@@ -6,6 +6,7 @@ import Overview from './config/configSections/Overview';
import { createConfigNavSections } from './config/configNavSections';
import { NavKey } from './config/types';
import './AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
interface AppConfigModalProps {
opened: boolean;
@@ -77,7 +78,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
centered
radius="lg"
withCloseButton={false}
style={{ zIndex: 1000 }}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
+101
View File
@@ -0,0 +1,101 @@
import React from 'react';
import { Box } from '@mantine/core';
interface BadgeProps {
children: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
variant?: 'default' | 'colored';
color?: string;
textColor?: string;
backgroundColor?: string;
className?: string;
style?: React.CSSProperties;
}
const Badge: React.FC<BadgeProps> = ({
children,
size = 'sm',
variant = 'default',
color,
textColor,
backgroundColor,
className,
style
}) => {
const getSizeStyles = () => {
switch (size) {
case 'sm':
return {
padding: '0.125rem 0.5rem',
fontSize: '0.75rem',
fontWeight: 700,
borderRadius: '0.5rem',
};
case 'md':
return {
padding: '0.25rem 0.75rem',
fontSize: '0.875rem',
fontWeight: 700,
borderRadius: '0.625rem',
};
case 'lg':
return {
padding: '0.375rem 1rem',
fontSize: '1rem',
fontWeight: 700,
borderRadius: '0.75rem',
};
default:
return {};
}
};
const getVariantStyles = () => {
// If explicit colors are provided, use them
if (textColor && backgroundColor) {
return {
backgroundColor,
color: textColor,
};
}
// If a single color is provided, use it for text and 20% opacity for background
if (color) {
return {
backgroundColor: `color-mix(in srgb, ${color} 20%, transparent)`,
color: color,
};
}
// If variant is colored but no color provided, use default colored styling
if (variant === 'colored') {
return {
backgroundColor: `color-mix(in srgb, var(--category-color-default) 15%, transparent)`,
color: 'var(--category-color-default)',
borderColor: `color-mix(in srgb, var(--category-color-default) 30%, transparent)`,
border: '1px solid',
};
}
// Default styling
return {
background: 'var(--tool-header-badge-bg)',
color: 'var(--tool-header-badge-text)',
};
};
return (
<Box
className={className}
style={{
...getSizeStyles(),
...getVariantStyles(),
...style,
}}
>
{children}
</Box>
);
};
export default Badge;
@@ -125,6 +125,7 @@ const FilePickerModal = ({
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
zIndex={1100}
>
<Stack gap="md">
{storedFiles.length === 0 ? (
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LocalIcon from './LocalIcon';
import styles from './LanguageSelector.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
// Types
interface LanguageSelectorProps {
@@ -209,6 +210,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
width={600}
position={position}
offset={offset}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
transitionProps={{
transition: 'scale-y',
duration: 200,
@@ -264,6 +266,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<ScrollArea h={190} type="scroll">
@@ -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();
@@ -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,55 +51,53 @@ const NavigationWarningModal = ({
onClose={handleKeepWorking}
title={t("unsavedChangesTitle", "Unsaved Changes")}
centered
size="xl"
closeOnClickOutside={false}
closeOnEscape={false}
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
>
<Stack gap="xl">
<Text size="md">
{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="xl" mt="xl">
<Group gap="xl">
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
{t("discardChanges", "Discard Changes")}
</Button>
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={handleKeepWorking}
>
{/* 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} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
</Group>
<Group gap="xl">
{onExportAndContinue && (
<Button
variant="light"
onClick={handleExportAndContinue}
>
{t("exportAndContinue", "Export & Continue")}
</Button>
)}
<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="light"
color="blue"
onClick={handleApplyAndContinue}
>
{t("applyAndContinue", "Apply & Continue")}
<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>
);
@@ -24,7 +24,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const [configModalOpen, setConfigModalOpen] = useState(false);
@@ -68,7 +68,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick()
onClick: () => handleClick(),
'aria-label': config.name
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
@@ -98,12 +99,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
type: 'navigation',
onClick: () => {
setActiveButton('read');
handleBackToTools();
handleReaderToggle();
}
},
// TODO: Add sign
//{
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
@@ -114,7 +113,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
//},
// },
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
@@ -230,6 +229,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick={config.onClick}
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
aria-label={config.name}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
+46 -33
View File
@@ -20,18 +20,23 @@ import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
import { useSidebarContext } from '../../contexts/SidebarContext';
export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions } = useRightRail();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
// Access PageEditor functions for page-editor-specific actions
const { pageEditorFunctions } = useToolWorkflow();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
// CSV input state for page selection
const [csvInput, setCsvInput] = useState<string>("");
@@ -176,19 +181,19 @@ export default function RightRail() {
}, [currentView]);
return (
<div className="right-rail">
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
<div className="right-rail-inner">
{topButtons.length > 0 && (
<>
<div className="right-rail-section">
{topButtons.map(btn => (
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => actions[btn.id]?.()}
disabled={btn.disabled}
disabled={btn.disabled || allButtonsDisabled || disableForFullscreen}
>
{btn.icon}
</ActionIcon>
@@ -206,7 +211,7 @@ export default function RightRail() {
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Search */}
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow portalTarget={document.body}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
@@ -214,7 +219,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={currentView !== 'viewer'}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
@@ -234,7 +239,7 @@ export default function RightRail() {
{/* Pan Mode */}
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanning ? "filled" : "subtle"}
color={isPanning ? "blue" : undefined}
@@ -244,14 +249,14 @@ export default function RightRail() {
viewerContext?.panActions.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer'}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Left */}
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -259,14 +264,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateBackward();
}}
disabled={currentView !== 'viewer'}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Right */}
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -274,14 +279,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateForward();
}}
disabled={currentView !== 'viewer'}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Sidebar Toggle */}
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -289,14 +294,17 @@ export default function RightRail() {
onClick={() => {
viewerContext?.toggleThumbnailSidebar();
}}
disabled={currentView !== 'viewer'}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls currentView={currentView} />
<ViewerAnnotationControls
currentView={currentView}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
/>
</div>
<Divider className="right-rail-divider" />
</div>
@@ -308,14 +316,14 @@ export default function RightRail() {
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Select All Button */}
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleSelectAll}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -323,14 +331,14 @@ export default function RightRail() {
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleDeselectAll}
disabled={currentView === 'viewer' || selectedCount === 0}
disabled={currentView === 'viewer' || selectedCount === 0 || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -339,7 +347,7 @@ export default function RightRail() {
{/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<Popover position="left" withArrow shadow="md" offset={8}>
@@ -349,7 +357,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0}
disabled={!pageControlsVisible || totalItems === 0 || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
@@ -376,7 +384,7 @@ export default function RightRail() {
{/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
@@ -385,7 +393,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
>
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
@@ -398,7 +406,7 @@ export default function RightRail() {
{/* Export Selected Pages - page editor only */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
@@ -406,7 +414,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
@@ -417,7 +425,7 @@ export default function RightRail() {
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
@@ -427,7 +435,8 @@ export default function RightRail() {
disabled={
currentView === 'viewer' ||
(currentView === 'fileEditor' && selectedCount === 0) ||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) ||
allButtonsDisabled || disableForFullscreen
}
>
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
@@ -441,7 +450,8 @@ export default function RightRail() {
{/* Theme toggle and Language dropdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow portalTarget={document.body}
>
<ActionIcon
variant="subtle"
radius="md"
@@ -452,13 +462,17 @@ export default function RightRail() {
</ActionIcon>
</Tooltip>
<LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={t('rightRail.language', 'Language')} position="left" offset={12} arrow portalTarget={document.body}>
<div style={{ display: 'inline-flex' }}>
<LanguageSelector position="left-start" offset={6} compact />
</div>
</Tooltip>
<Tooltip content={
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow>
} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
@@ -466,7 +480,7 @@ export default function RightRail() {
className="right-rail-icon"
onClick={handleExportAll}
disabled={
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
disableForFullscreen || (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
@@ -481,4 +495,3 @@ export default function RightRail() {
);
}
+22 -10
View File
@@ -8,6 +8,7 @@ import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import { BASE_PATH } from '../../constants/app';
import styles from './tooltip/Tooltip.module.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
export interface TooltipProps {
sidebarTooltip?: boolean;
@@ -28,6 +29,10 @@ export interface TooltipProps {
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
/** If true, tooltip interaction is disabled entirely */
disabled?: boolean;
/** If false, tooltip will not open on focus (hover only) */
openOnFocus?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@@ -48,6 +53,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
disabled = false,
openOnFocus = true,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
@@ -68,7 +75,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
const isControlled = controlledOpen !== undefined;
const open = isControlled ? !!controlledOpen : internalOpen;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const setOpen = useCallback(
(newOpen: boolean) => {
@@ -148,15 +155,16 @@ export const Tooltip: React.FC<TooltipProps> = ({
// === Trigger handlers ===
const openWithDelay = useCallback(() => {
clearTimers();
if (disabled) return;
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
}, [clearTimers, setOpen, delay]);
}, [clearTimers, setOpen, delay, disabled]);
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned) openWithDelay();
if (!isPinned && !disabled) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props]
[isPinned, openWithDelay, children.props, disabled]
);
const handlePointerLeave = useCallback(
@@ -219,10 +227,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned) openWithDelay();
if (!isPinned && !disabled && openOnFocus) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props]
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
);
const handleBlur = useCallback(
@@ -291,7 +299,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: 9999,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
color: 'var(--text-primary)',
@@ -345,9 +353,13 @@ export const Tooltip: React.FC<TooltipProps> = ({
return (
<>
{childWithHandlers}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
{(() => {
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
const target = portalTarget ?? defaultTarget;
return tooltipElement && target
? createPortal(tooltipElement, target)
: tooltipElement;
})()}
</>
);
};
@@ -1,6 +1,7 @@
import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
export interface ConfigNavItem {
key: NavKey;
@@ -43,6 +44,12 @@ export const createConfigNavSections = (
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
@@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
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">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<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;
@@ -1,10 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text } from '@mantine/core';
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
import { useHotkeys } from '../../../../contexts/HotkeyContext';
import { ToolId } from '../../../../types/toolId';
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys';
import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys';
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
const rowStyle: React.CSSProperties = {
display: 'flex',
@@ -24,10 +26,22 @@ const HotkeysSection: React.FC = () => {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<string | null>(null);
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const tools = useMemo(() => Object.entries(toolRegistry), [toolRegistry]);
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
const filteredTools = useMemo(() => {
if (!searchQuery.trim()) return tools;
const query = searchQuery.toLowerCase();
return tools.filter(([toolId, tool]) =>
tool.name.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query) ||
toolId.toLowerCase().includes(query)
);
}, [tools, searchQuery]);
useEffect(() => {
if (!editingTool) {
@@ -45,15 +59,17 @@ const HotkeysSection: React.FC = () => {
}
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setEditingTool(null);
setError(null);
return;
}
event.preventDefault();
event.stopPropagation();
const binding = eventToBinding(event as KeyboardEvent);
if (!binding) {
const osKey = isMac ? 'mac' : 'windows';
@@ -64,12 +80,15 @@ const HotkeysSection: React.FC = () => {
return;
}
const conflictEntry = Object.entries(hotkeys).find(([toolId, existing]) => (
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];
const conflictKey = conflictEntry[0];
const conflictTool = (conflictKey in toolRegistry)
? toolRegistry[conflictKey as ToolId]?.name
: conflictKey;
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
return;
}
@@ -85,7 +104,7 @@ const HotkeysSection: React.FC = () => {
};
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
const handleStartCapture = (toolId: string) => {
const handleStartCapture = (toolId: ToolId) => {
setEditingTool(toolId);
setError(null);
};
@@ -99,9 +118,22 @@ const HotkeysSection: React.FC = () => {
</Text>
</div>
<TextInput
placeholder={t('settings.hotkeys.searchPlaceholder', 'Search tools...')}
value={searchQuery}
onChange={(event) => setSearchQuery(event.currentTarget.value)}
size="md"
radius="md"
/>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{tools.map(([toolId, tool], index) => {
{filteredTools.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t('toolPicker.noToolsFound', 'No tools found')}
</Text>
) : (
filteredTools.map(([toolId, tool], index) => {
const currentBinding = hotkeys[toolId];
const defaultBinding = defaults[toolId];
const isEditing = editingTool === toolId;
@@ -158,14 +190,15 @@ const HotkeysSection: React.FC = () => {
)}
</Box>
{index < tools.length - 1 && <Divider />}
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})}
})
)}
</Stack>
</Paper>
</Stack>
);
};
export default HotkeysSection;
export default HotkeysSection;
@@ -51,7 +51,6 @@ const Overview: React.FC = () => {
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
@@ -46,6 +46,13 @@
background-color: transparent !important;
}
/* When all buttons are disabled via context */
.right-rail--all-disabled .right-rail-icon {
color: var(--right-rail-icon-disabled) !important;
background-color: transparent !important;
pointer-events: none !important;
}
.right-rail-spacer {
flex: 1;
}
@@ -10,12 +10,14 @@ 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;
disabled?: boolean;
}
export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) {
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
@@ -25,13 +27,17 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef } = useSignature();
const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
@@ -39,10 +45,15 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
}
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -50,7 +61,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -94,7 +105,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer'}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
@@ -119,7 +130,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -136,7 +147,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer'}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
@@ -145,7 +156,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
@@ -193,7 +204,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
}
}
}}
disabled={currentView !== 'viewer'}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -0,0 +1,253 @@
import React, { useMemo } from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ToolRegistryEntry, getSubcategoryLabel, getSubcategoryColor, getSubcategoryIcon } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import { useToolSections } from '../../hooks/useToolSections';
import NoToolsFound from './shared/NoToolsFound';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import ThumbUpRoundedIcon from '@mui/icons-material/ThumbUpRounded';
import Badge from '../shared/Badge';
import './ToolPanel.css';
import DetailedToolItem from './fullscreen/DetailedToolItem';
import CompactToolItem from './fullscreen/CompactToolItem';
import { useFavoriteToolItems } from '../../hooks/tools/useFavoriteToolItems';
interface FullscreenToolListProps {
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
searchQuery: string;
showDescriptions: boolean;
selectedToolKey: string | null;
matchedTextMap: Map<string, string>;
onSelect: (id: ToolId) => void;
}
const FullscreenToolList = ({
filteredTools,
searchQuery,
showDescriptions,
selectedToolKey,
matchedTextMap: _matchedTextMap,
onSelect,
}: FullscreenToolListProps) => {
const { t } = useTranslation();
const { toolRegistry, favoriteTools } = useToolWorkflow();
const { sections, searchGroups } = useToolSections(filteredTools, searchQuery);
const tooltipPortalTarget = typeof document !== 'undefined' ? document.body : undefined;
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const quickSection = useMemo(() => sections.find(section => section.key === 'quick'), [sections]);
const recommendedItems = useMemo(() => {
if (!quickSection) return [] as Array<{ id: string, tool: ToolRegistryEntry }>;
const items: Array<{ id: string, tool: ToolRegistryEntry }> = [];
quickSection.subcategories.forEach(sc => sc.tools.forEach(t => items.push(t)));
return items;
}, [quickSection]);
// Show recommended/favorites section only when not searching
const showRecentFavorites = searchQuery.trim().length === 0 && ((recommendedItems.length > 0) || favoriteToolItems.length > 0);
const subcategoryGroups = useMemo(() => {
if (searchQuery.trim().length > 0) {
return searchGroups;
}
const allSection = sections.find(section => section.key === 'all');
return allSection ? allSection.subcategories : [];
}, [searchGroups, sections, searchQuery]);
if (subcategoryGroups.length === 0 && !showRecentFavorites) {
return (
<div className="tool-panel__fullscreen-empty">
<NoToolsFound />
<Text size="sm" c="dimmed">
{t('toolPanel.fullscreen.noResults', 'Try adjusting your search or toggle descriptions to find what you need.')}
</Text>
</div>
);
}
const containerClass = showDescriptions
? 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--detailed'
: 'tool-panel__fullscreen-groups tool-panel__fullscreen-groups--compact';
// Helper function to render a tool item
const renderToolItem = (id: ToolId, tool: ToolRegistryEntry) => {
const isSelected = selectedToolKey === id;
const handleClick = () => {
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') return;
if (tool.link) {
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
}
onSelect(id as ToolId);
};
if (showDescriptions) {
return (
<DetailedToolItem key={id} id={id} tool={tool} isSelected={isSelected} onClick={handleClick} />
);
}
return (
<CompactToolItem key={id} id={id} tool={tool} isSelected={isSelected} onClick={handleClick} tooltipPortalTarget={tooltipPortalTarget} />
);
};
return (
<div className={containerClass}>
{showRecentFavorites && (
<>
{favoriteToolItems.length > 0 && (
<section
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
style={{
borderColor: 'var(--fullscreen-border-favorites)',
}}
>
<header className="tool-panel__fullscreen-section-header">
<div className="tool-panel__fullscreen-section-title">
<span
className="tool-panel__fullscreen-section-icon"
style={{
color: 'var(--special-color-favorites)',
}}
aria-hidden
>
<StarRoundedIcon />
</span>
<Text size="sm" fw={600} tt="uppercase" lts={0.5} c="dimmed">
{t('toolPanel.fullscreen.favorites', 'Favourites')}
</Text>
</div>
<Badge
size="sm"
variant="colored"
color="var(--special-color-favorites)"
>
{favoriteToolItems.length}
</Badge>
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))}
</div>
)}
</section>
)}
{recommendedItems.length > 0 && (
<section
className="tool-panel__fullscreen-group tool-panel__fullscreen-group--special"
style={{
borderColor: 'var(--fullscreen-border-recommended)',
}}
>
<header className="tool-panel__fullscreen-section-header">
<div className="tool-panel__fullscreen-section-title">
<span
className="tool-panel__fullscreen-section-icon"
style={{
color: 'var(--special-color-recommended)',
}}
aria-hidden
>
<ThumbUpRoundedIcon />
</span>
<Text size="sm" fw={600} tt="uppercase" lts={0.5} c="dimmed">
{t('toolPanel.fullscreen.recommended', 'Recommended')}
</Text>
</div>
<Badge
size="sm"
variant="colored"
color="var(--special-color-recommended)"
>
{recommendedItems.length}
</Badge>
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))}
</div>
)}
</section>
)}
</>
)}
{subcategoryGroups.map(({ subcategoryId, tools }) => {
const categoryColor = getSubcategoryColor(subcategoryId);
return (
<section
key={subcategoryId}
className={`tool-panel__fullscreen-group ${showDescriptions ? 'tool-panel__fullscreen-group--detailed' : 'tool-panel__fullscreen-group--compact'}`}
style={{
borderColor: `color-mix(in srgb, ${categoryColor} 25%, var(--fullscreen-border-subtle-65))`,
}}
>
<header className="tool-panel__fullscreen-section-header">
<div className="tool-panel__fullscreen-section-title">
<span
className="tool-panel__fullscreen-section-icon"
style={{
color: categoryColor,
}}
aria-hidden
>
{getSubcategoryIcon(subcategoryId)}
</span>
<Text
size="sm"
fw={600}
tt="uppercase"
lts={0.5}
style={{
color: categoryColor,
}}
>
{getSubcategoryLabel(t, subcategoryId)}
</Text>
</div>
<Badge
size="sm"
variant="colored"
color={categoryColor}
>
{tools.length}
</Badge>
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))}
</div>
)}
</section>
);
})}
</div>
);
};
export default FullscreenToolList;
@@ -0,0 +1,159 @@
import { useState, useRef } from 'react';
import { ActionIcon, ScrollArea, Switch, useMantineColorScheme } from '@mantine/core';
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import { useTranslation } from 'react-i18next';
import ToolSearch from './toolPicker/ToolSearch';
import FullscreenToolList from './FullscreenToolList';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import { BASE_PATH } from '../../constants/app';
import { Tooltip } from '../shared/Tooltip';
import './ToolPanel.css';
import { ToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
interface FullscreenToolSurfaceProps {
searchQuery: string;
toolRegistry: Partial<Record<ToolId, ToolRegistryEntry>>;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
selectedToolKey: string | null;
showDescriptions: boolean;
matchedTextMap: Map<string, string>;
onSearchChange: (value: string) => void;
onSelect: (id: ToolId) => void;
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
toggleLabel: string;
geometry: ToolPanelGeometry | null;
}
const FullscreenToolSurface = ({
searchQuery,
toolRegistry,
filteredTools,
selectedToolKey,
showDescriptions,
matchedTextMap,
onSearchChange,
onSelect,
onToggleDescriptions,
onExitFullscreenMode,
toggleLabel,
geometry,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const { colorScheme } = useMantineColorScheme();
const [isExiting, setIsExiting] = useState(false);
const surfaceRef = useRef<HTMLDivElement>(null);
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, !isExiting);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
colorScheme === "dark" ? "Dark" : "Light"
}.svg`;
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
colorScheme === "dark" ? "White" : "Black"
}Text.svg`;
const handleExit = () => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
onExitFullscreenMode();
return;
}
setIsExiting(true);
const el = surfaceRef.current;
if (!el) {
onExitFullscreenMode();
return;
}
// Rely on CSS animation end rather than duplicating timing in JS
el.addEventListener('animationend', () => {
onExitFullscreenMode();
}, { once: true });
};
const style = geometry
? {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
}
: undefined;
return (
<div
className="tool-panel__fullscreen-surface"
style={style}
role="region"
aria-label={t('toolPanel.fullscreen.heading', 'All tools (fullscreen view)')}
>
<div
ref={surfaceRef}
className={`tool-panel__fullscreen-surface-inner ${isExiting ? 'tool-panel__fullscreen-surface-inner--exiting' : ''}`}
>
<header className="tool-panel__fullscreen-header">
<div className="tool-panel__fullscreen-brand">
<img src={brandIconSrc} alt="" className="tool-panel__fullscreen-brand-icon" />
<img src={brandTextSrc} alt={brandAltText} className="tool-panel__fullscreen-brand-text" />
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Tooltip content={toggleLabel} position="bottom" arrow={true} openOnFocus={false} containerStyle={{ zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE }}>
<ActionIcon
variant="subtle"
radius="xl"
size="md"
onClick={handleExit}
aria-label={toggleLabel}
style={{ color: 'var(--right-rail-icon)' }}
>
<DoubleArrowIcon fontSize="small" style={{ transform: 'rotate(180deg)' }} />
</ActionIcon>
</Tooltip>
</div>
</header>
<div className="tool-panel__fullscreen-controls">
<ToolSearch
value={searchQuery}
onChange={onSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus
/>
<Switch
checked={showDescriptions}
onChange={() => onToggleDescriptions()}
size="md"
labelPosition="left"
label={t('toolPanel.fullscreen.showDetails', 'Show Details')}
/>
</div>
<div className="tool-panel__fullscreen-body">
<ScrollArea className="tool-panel__fullscreen-scroll" offsetScrollbars>
<FullscreenToolList
filteredTools={filteredTools}
searchQuery={searchQuery}
showDescriptions={showDescriptions}
selectedToolKey={selectedToolKey}
matchedTextMap={matchedTextMap}
onSelect={onSelect}
/>
</ScrollArea>
</div>
</div>
</div>
);
};
export default FullscreenToolSurface;
@@ -1,6 +1,7 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
import { useToolSections } from '../../hooks/useToolSections';
@@ -9,7 +10,7 @@ import NoToolsFound from './shared/NoToolsFound';
import "./toolPicker/ToolPicker.css";
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}
+548
View File
@@ -0,0 +1,548 @@
/* CSS Custom Properties for Fullscreen Mode */
.tool-panel__fullscreen-surface-inner {
--fullscreen-bg-surface-1: color-mix(in srgb, var(--bg-toolbar) 96%, transparent);
--fullscreen-bg-surface-2: color-mix(in srgb, var(--bg-background) 90%, transparent);
--fullscreen-bg-header: var(--bg-toolbar);
--fullscreen-bg-controls-1: var(--bg-toolbar);
--fullscreen-bg-controls-2: color-mix(in srgb, var(--bg-toolbar) 95%, var(--bg-background));
--fullscreen-bg-body-1: color-mix(in srgb, var(--bg-background) 86%, transparent);
--fullscreen-bg-body-2: color-mix(in srgb, var(--bg-toolbar) 78%, transparent);
--fullscreen-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent);
--fullscreen-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent);
--fullscreen-bg-list-item: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
--fullscreen-bg-icon-detailed: color-mix(in srgb, var(--bg-muted) 75%, transparent);
--fullscreen-bg-icon-compact: color-mix(in srgb, var(--bg-muted) 70%, transparent);
--fullscreen-border-subtle-75: color-mix(in srgb, var(--border-subtle) 75%, transparent);
--fullscreen-border-subtle-70: color-mix(in srgb, var(--border-subtle) 70%, transparent);
--fullscreen-border-subtle-65: color-mix(in srgb, var(--border-subtle) 65%, transparent);
--fullscreen-border-favorites: color-mix(in srgb, var(--special-color-favorites) 25%, var(--border-subtle));
--fullscreen-border-recommended: color-mix(in srgb, var(--special-color-recommended) 25%, var(--border-subtle));
--fullscreen-shadow-primary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent);
--fullscreen-shadow-secondary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent);
--fullscreen-shadow-group: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent);
--fullscreen-accent-hover: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
--fullscreen-accent-selected: color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
--fullscreen-accent-ring: color-mix(in srgb, var(--text-primary) 15%, transparent);
--fullscreen-accent-list-bg: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar));
--fullscreen-accent-list-border: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
--fullscreen-text-icon: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted));
--fullscreen-text-icon-compact: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted));
}
.tool-panel {
position: relative;
transition: width 0.3s ease, max-width 0.3s ease;
}
.tool-panel--fullscreen-active {
overflow: visible !important;
}
.tool-panel__search-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.tool-panel__search-row .search-input-container {
flex: 1 1 auto;
}
.tool-panel__mode-toggle {
transition: transform 0.2s ease;
}
.tool-panel__mode-toggle:hover {
transform: scale(1.04);
}
.tool-panel--fullscreen {
background: var(--bg-toolbar);
}
.tool-panel__placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.9rem;
padding: 1.5rem;
text-align: center;
}
.tool-panel__fullscreen-surface {
position: fixed;
display: flex;
pointer-events: none;
z-index: var(--z-fullscreen-surface);
top: 0;
left: 0;
width: 0;
height: 0;
}
.tool-panel__fullscreen-surface-inner {
pointer-events: auto;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
border-radius: 0;
background:
linear-gradient(
140deg,
var(--fullscreen-bg-surface-1),
var(--fullscreen-bg-surface-2)
)
padding-box;
border: 1px solid var(--fullscreen-border-subtle-75);
box-shadow: none;
backdrop-filter: blur(18px);
overflow: hidden;
/* Shared animation durations for JS + CSS (sourced from theme.css) */
--fullscreen-anim-in-duration: var(--fullscreen-anim-duration-in);
--fullscreen-anim-out-duration: var(--fullscreen-anim-duration-out);
animation: tool-panel-fullscreen-slide-in var(--fullscreen-anim-in-duration) ease forwards;
}
.tool-panel__fullscreen-surface-inner--exiting {
animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) ease forwards;
}
.tool-panel__fullscreen-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.75rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
}
.tool-panel__fullscreen-brand {
display: flex;
align-items: center;
gap: 0.625rem;
}
.tool-panel__fullscreen-brand-icon {
height: 1.75rem;
width: auto;
}
.tool-panel__fullscreen-brand-text {
height: 1.25rem;
width: auto;
}
.tool-panel__fullscreen-controls {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.75rem;
border-bottom: 1px solid var(--tool-panel-search-border-bottom);
background: var(--tool-panel-search-bg);
}
.tool-panel__fullscreen-controls .search-input-container {
flex: 1 1 auto;
}
.tool-panel__fullscreen-body {
flex: 1;
min-height: 0;
background: transparent;
}
.tool-panel__fullscreen-scroll {
height: 100%;
}
/* fullscreen group layout */
.tool-panel__fullscreen-groups {
padding: 1.5rem 1.75rem;
column-width: 18rem;
column-gap: 1.5rem;
}
.tool-panel__fullscreen-groups--compact {
column-width: 17rem;
}
.tool-panel__fullscreen-groups--detailed {
column-width: auto;
}
.tool-panel__fullscreen-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
margin: 0 0 1.5rem;
padding: 0.65rem 0.75rem 1rem;
border-radius: 1rem;
background: var(--fullscreen-bg-group);
border: 1px solid var(--fullscreen-border-subtle-65);
box-shadow: 0 14px 32px var(--fullscreen-shadow-group);
break-inside: avoid;
backdrop-filter: blur(10px);
}
.tool-panel__fullscreen-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.1rem 0.15rem 0.35rem;
}
.tool-panel__fullscreen-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tool-panel__fullscreen-section-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.tool-panel__fullscreen-grid {
display: grid;
gap: 0.75rem;
}
.tool-panel__fullscreen-grid--detailed {
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
}
.tool-panel__fullscreen-item {
all: unset;
display: flex;
flex-direction: row;
gap: 0.75rem;
align-items: flex-start;
padding: 0.85rem 0.95rem;
border: 1px solid var(--fullscreen-border-subtle-70);
border-radius: 0.95rem;
background: var(--fullscreen-bg-item);
backdrop-filter: blur(6px);
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
width: 100%;
box-sizing: border-box;
/* Allow flex children to shrink without forcing horizontal overflow */
min-width: 0;
}
.tool-panel__fullscreen-item:focus-visible {
outline: 2px solid var(--fullscreen-accent-selected);
outline-offset: 3px;
}
.tool-panel__fullscreen-item[aria-disabled="true"],
.tool-panel__fullscreen-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]):not(:disabled) {
transform: translateY(-2px);
border-color: var(--fullscreen-accent-hover);
box-shadow: var(--shadow-xl, 0 18px 34px rgba(15, 23, 42, 0.14));
}
.tool-panel__fullscreen-item--selected {
border-color: var(--fullscreen-accent-selected);
box-shadow: 0 0 0 2px var(--fullscreen-accent-ring);
}
.tool-panel__fullscreen-item--detailed {
min-height: 7.5rem;
}
.tool-panel__fullscreen-item--with-star {
position: relative;
}
.tool-panel__fullscreen-star {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.tool-panel__fullscreen-item:hover .tool-panel__fullscreen-star {
opacity: 1;
}
.tool-panel__fullscreen-list-item--with-star {
position: relative;
}
.tool-panel__fullscreen-star-compact {
position: absolute;
top: 0.35rem;
right: 0.35rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.tool-panel__fullscreen-list-item:hover .tool-panel__fullscreen-star-compact {
opacity: 1;
}
.tool-panel__fullscreen-group--special {
border-width: 1px;
}
.tool-panel__fullscreen-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
background: var(--fullscreen-bg-icon-detailed);
color: var(--fullscreen-text-icon);
flex-shrink: 0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.tool-panel__fullscreen-icon::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg,
color-mix(in srgb, var(--text-primary) 12%, transparent),
color-mix(in srgb, var(--text-primary) 4%, transparent)
);
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 0.75rem;
}
.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon {
transform: scale(1.08);
box-shadow: 0 4px 12px color-mix(in srgb, var(--text-primary) 15%, transparent);
}
.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon::before {
opacity: 1;
}
.tool-panel__fullscreen-icon svg {
font-size: 1.65rem;
position: relative;
z-index: var(--z-fullscreen-icon-svg);
}
.tool-panel__fullscreen-body {
display: flex;
flex-direction: column;
gap: 0.4rem;
text-align: left;
/* Prevent long content (names, descriptions, shortcuts) from overflowing */
flex: 1 1 auto;
min-width: 0;
}
.tool-panel__fullscreen-name {
color: var(--text-primary);
}
.tool-panel__fullscreen-description {
line-height: 1.45;
}
/* Ensure long words or unbroken strings wrap instead of overflowing */
.tool-panel__fullscreen-name,
.tool-panel__fullscreen-description,
.tool-panel__fullscreen-match {
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.tool-panel__fullscreen-match {
font-style: italic;
}
.tool-panel__fullscreen-shortcut {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.2rem;
/* Allow hotkey chips to wrap on small widths to avoid overflow */
flex-wrap: wrap;
row-gap: 0.25rem;
}
.tool-panel__fullscreen-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.tool-panel__fullscreen-list-item {
all: unset;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.5rem 0.55rem 0.65rem;
border-radius: 0.65rem;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
background: var(--fullscreen-bg-list-item);
border: 1px solid transparent;
width: 100%;
box-sizing: border-box;
position: relative;
}
.tool-panel__fullscreen-list-item[aria-disabled="true"],
.tool-panel__fullscreen-list-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]):not(:disabled),
.tool-panel__fullscreen-list-item--selected {
background: var(--fullscreen-accent-list-bg);
border-color: var(--fullscreen-accent-list-border);
}
.tool-panel__fullscreen-list-item--selected {
transform: translateX(2px);
}
.tool-panel__fullscreen-list-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.6rem;
background: var(--fullscreen-bg-icon-compact);
color: var(--fullscreen-text-icon-compact);
flex-shrink: 0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
overflow: hidden;
}
.tool-panel__fullscreen-list-icon::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg,
color-mix(in srgb, var(--text-primary) 10%, transparent),
color-mix(in srgb, var(--text-primary) 3%, transparent)
);
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 0.6rem;
}
.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon {
transform: scale(1.06);
box-shadow: 0 2px 8px color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon::before {
opacity: 1;
}
.tool-panel__fullscreen-list-icon svg {
position: relative;
z-index: 1;
}
.tool-panel__fullscreen-list-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
text-align: left;
}
.tool-panel__fullscreen-empty {
padding: 2rem 1.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@keyframes tool-panel-fullscreen-slide-in {
from {
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
}
to {
transform: translateX(0) scaleX(1);
opacity: 1;
}
}
@keyframes tool-panel-fullscreen-slide-out {
from {
transform: translateX(0) scaleX(1);
opacity: 1;
}
to {
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.tool-panel__fullscreen-surface-inner {
animation: none !important;
}
.tool-panel__mode-toggle {
transition: none;
}
.tool-panel__fullscreen-item,
.tool-panel__fullscreen-list-item {
transition: none;
}
}
@media (max-width: 1440px) {
.tool-panel__fullscreen-content {
padding-inline: 1.5rem;
}
.tool-panel__fullscreen-grid--compact {
column-width: 15rem;
}
}
@media (max-width: 1280px) {
.tool-panel__fullscreen-controls {
flex-direction: column;
align-items: stretch;
}
.tool-panel__fullscreen-controls .mantine-Switch-root {
justify-content: flex-end;
}
.tool-panel__fullscreen-grid--compact {
column-width: 14rem;
}
}
+168 -63
View File
@@ -1,3 +1,4 @@
import React, { useEffect, useMemo } from 'react';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
@@ -6,20 +7,27 @@ import ToolRenderer from './ToolRenderer';
import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
import { ScrollArea } from '@mantine/core';
import { ActionIcon, ScrollArea } from '@mantine/core';
import { ToolId } from '../../types/toolId';
import { useMediaQuery } from '@mantine/hooks';
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import { useTranslation } from 'react-i18next';
import FullscreenToolSurface from './FullscreenToolSurface';
import { useToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
import { useLocalStorageState } from '../../hooks/tools/useJsonLocalStorageState';
import { useRightRail } from '../../contexts/RightRailContext';
import { Tooltip } from '../shared/Tooltip';
import './ToolPanel.css';
// No props needed - component uses context
export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
// Use context-based hooks to eliminate prop drilling
const {
leftPanelView,
isPanelVisible,
@@ -27,84 +35,181 @@ export default function ToolPanel() {
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
handleToolSelect,
setPreviewFile,
toolPanelMode,
setToolPanelMode,
setLeftPanelView,
readerMode,
} = useToolWorkflow();
const { selectedToolKey, handleToolSelect } = useToolWorkflow();
const { setPreviewFile } = useToolWorkflow();
const { setAllRightRailButtonsDisabled } = useRightRail();
const isFullscreenMode = toolPanelMode === 'fullscreen';
const toolPickerVisible = !readerMode;
const fullscreenExpanded = isFullscreenMode && leftPanelView === 'toolPicker' && !isMobile && toolPickerVisible;
// Disable right rail buttons when fullscreen mode is active
useEffect(() => {
setAllRightRailButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllRightRailButtonsDisabled]);
// Use custom hooks for state management
const [showLegacyDescriptions, setShowLegacyDescriptions] = useLocalStorageState('legacyToolDescriptions', false);
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
rightRailRef,
});
const toggleLabel = isFullscreenMode
? t('toolPanel.toggle.sidebar', 'Switch to sidebar mode')
: t('toolPanel.toggle.fullscreen', 'Switch to fullscreen mode');
const handleModeToggle = () => {
const nextMode = isFullscreenMode ? 'sidebar' : 'fullscreen';
setToolPanelMode(nextMode);
if (nextMode === 'fullscreen' && leftPanelView !== 'toolPicker') {
setLeftPanelView('toolPicker');
}
};
const computedWidth = () => {
if (isMobile) {
return '100%';
}
if (!isPanelVisible) {
return '0';
}
return '18.5rem';
};
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) {
map.set(id, matchedText);
}
});
return map;
}, [filteredTools]);
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
className={`flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
className={`tool-panel flex flex-col ${fullscreenExpanded ? 'tool-panel--fullscreen-active' : 'overflow-hidden'} bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ''
} ${isMobile ? 'h-full border-r-0' : 'h-screen'}`}
} ${isMobile ? 'h-full border-r-0' : 'h-screen'} ${fullscreenExpanded ? 'tool-panel--fullscreen' : ''}`}
style={{
width: isMobile ? '100%' : isPanelVisible ? '18.5rem' : '0',
width: computedWidth(),
padding: '0'
}}
>
<div
style={{
opacity: isMobile || isPanelVisible ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
{/* Search Bar - Always visible at the top */}
{!fullscreenExpanded && (
<div
style={{
backgroundColor: 'var(--tool-panel-search-bg)',
borderBottom: '1px solid var(--tool-panel-search-border-bottom)',
padding: '0.75rem 1rem',
opacity: isMobile || isPanelVisible ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
</div>
{searchQuery.trim().length > 0 ? (
// Searching view (replaces both picker and content)
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (
// Tool Picker View
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
<div
className="tool-panel__search-row"
style={{
backgroundColor: 'var(--tool-panel-search-bg)',
borderBottom: '1px solid var(--tool-panel-search-border-bottom)'
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
/>
{!isMobile && leftPanelView === 'toolPicker' && (
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
openOnFocus={false}
>
<ActionIcon
variant="subtle"
radius="xl"
style={{ color: 'var(--right-rail-icon)' }}
onClick={handleModeToggle}
aria-label={toggleLabel}
className="tool-panel__mode-toggle"
>
<DoubleArrowIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
</div>
) : (
// Selected Tool Content View
<div className="flex-1 flex flex-col overflow-hidden">
{/* Tool content */}
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey && (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
)}
</ScrollArea>
{searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t('toolPanel.placeholder', 'Choose a tool to get started')}
</div>
)}
</ScrollArea>
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{fullscreenExpanded && (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={showLegacyDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() => setShowLegacyDescriptions((prev) => !prev)}
onExitFullscreenMode={() => setToolPanelMode('sidebar')}
toggleLabel={toggleLabel}
geometry={fullscreenGeometry}
/>
)}
</div>
);
}
@@ -0,0 +1,200 @@
.tool-panel-mode-prompt__modal {
background: color-mix(in srgb, var(--bg-toolbar) 94%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
box-shadow: 0 32px 64px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 20%, transparent);
max-width: min(46rem, 100%);
}
.tool-panel-mode-prompt__options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
.tool-panel-mode-prompt__card {
display: flex;
flex-direction: column;
gap: 1rem;
background: linear-gradient(145deg,
color-mix(in srgb, var(--bg-surface) 96%, transparent),
color-mix(in srgb, var(--bg-muted) 70%, transparent)
);
width: 100%;
max-width: 19rem;
}
.tool-panel-mode-prompt__card--sidebar {
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 18%, var(--border-subtle));
background: linear-gradient(165deg,
color-mix(in srgb, var(--bg-surface) 96%, transparent),
color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 8%, transparent)
);
}
.tool-panel-mode-prompt__preview {
border-radius: 0.9rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--bg-muted) 82%, transparent), transparent 75%);
padding: 0.75rem;
width: 100%;
display: flex;
gap: 0.65rem;
min-height: 6.5rem;
margin-top: auto;
}
.tool-panel-mode-prompt__preview--sidebar {
align-items: stretch;
}
.tool-panel-mode-prompt__sidebar-panel {
width: 3rem;
border-radius: 0.65rem;
padding: 0.45rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
background: linear-gradient(180deg,
color-mix(in srgb, var(--bg-muted) 88%, transparent),
color-mix(in srgb, var(--bg-muted) 72%, transparent)
);
border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent);
}
.tool-panel-mode-prompt__sidebar-search {
height: 0.5rem;
border-radius: 0.4rem;
background: color-mix(in srgb, var(--bg-background) 90%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle) 60%, transparent);
}
.tool-panel-mode-prompt__sidebar-item {
height: 0.55rem;
border-radius: 0.35rem;
background: color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 32%, var(--bg-muted));
}
.tool-panel-mode-prompt__sidebar-item--muted {
background: color-mix(in srgb, var(--bg-background) 88%, transparent);
}
.tool-panel-mode-prompt__workspace {
flex: 1;
border-radius: 0.65rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent);
padding: 0.5rem;
display: grid;
gap: 0.35rem;
grid-template-rows: 1.4fr 0.6fr;
background: linear-gradient(160deg,
color-mix(in srgb, var(--bg-background) 94%, transparent),
color-mix(in srgb, var(--bg-muted) 68%, transparent)
);
}
.tool-panel-mode-prompt__workspace-page {
border-radius: 0.45rem;
background: color-mix(in srgb, var(--bg-surface) 96%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bg-background) 60%, transparent);
}
.tool-panel-mode-prompt__workspace-page--secondary {
opacity: 0.55;
}
.tool-panel-mode-prompt__preview--legacy {
align-items: flex-start;
justify-content: center;
padding: 0.65rem 0.6rem;
}
.tool-panel-mode-prompt__legacy-columns {
width: 100%;
display: flex;
gap: 0.45rem;
justify-content: space-between;
}
.tool-panel-mode-prompt__legacy-column {
flex: 1;
display: grid;
gap: 0.3rem;
}
.tool-panel-mode-prompt__legacy-card {
border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent);
background: linear-gradient(150deg,
color-mix(in srgb, var(--bg-muted) 88%, transparent),
color-mix(in srgb, var(--bg-background) 76%, transparent)
);
height: 1.2rem;
}
.tool-panel-mode-prompt__legacy-card--muted {
opacity: 0.55;
}
.tool-panel-mode-prompt__preview--fullscreen {
align-items: flex-start;
justify-content: center;
padding: 0.65rem 0.6rem;
}
.tool-panel-mode-prompt__fullscreen-columns {
width: 100%;
display: flex;
gap: 0.45rem;
justify-content: space-between;
}
.tool-panel-mode-prompt__fullscreen-column {
flex: 1;
display: grid;
gap: 0.3rem;
}
.tool-panel-mode-prompt__fullscreen-card {
border-radius: 0.45rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent);
background: linear-gradient(150deg,
color-mix(in srgb, var(--bg-muted) 88%, transparent),
color-mix(in srgb, var(--bg-background) 76%, transparent)
);
height: 1.2rem;
}
.tool-panel-mode-prompt__fullscreen-card--muted {
opacity: 0.55;
}
.tool-panel-mode-prompt__card-content {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
.tool-panel-mode-prompt__action {
box-shadow: none;
}
.tool-panel-mode-prompt__action:hover {
box-shadow: 0 10px 18px color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 25%, transparent);
}
.tool-panel-mode-prompt__maybe-later {
color: color-mix(in srgb, var(--text-secondary) 90%, var(--text-muted));
}
.tool-panel-mode-prompt__maybe-later:hover {
background: color-mix(in srgb, var(--bg-muted) 78%, transparent);
}
@media (max-width: 600px) {
.tool-panel-mode-prompt__options {
grid-template-columns: 1fr;
justify-items: stretch;
}
}
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import './ToolPanelModePrompt.css';
import { useToolPanelModePreference } from '../../hooks/useToolPanelModePreference';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
// type moved to hook
const ToolPanelModePrompt = () => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const [opened, setOpened] = useState(false);
const { hydrated, shouldShowPrompt, markPromptSeen, setPreferredMode } = useToolPanelModePreference();
useEffect(() => {
if (shouldShowPrompt) {
setOpened(true);
}
}, [shouldShowPrompt]);
const handleSelect = (mode: ToolPanelMode) => {
setToolPanelMode(mode);
setPreferredMode(mode);
markPromptSeen();
setOpened(false);
};
const handleDismiss = () => {
markPromptSeen();
setOpened(false);
};
if (!hydrated) {
return null;
}
return (
<Modal
opened={opened}
onClose={handleDismiss}
centered
size="xl"
radius="lg"
overlayProps={{ blur: 6, opacity: 0.35 }}
classNames={{ content: 'tool-panel-mode-prompt__modal' }}
title={t('toolPanel.modePrompt.title', 'Choose how you browse tools')}
>
<Stack gap="lg">
<Text size="sm" c="dimmed">
{t('toolPanel.modePrompt.description', 'Preview both layouts and decide how you want to explore Stirling PDF tools.')}
</Text>
<div className="tool-panel-mode-prompt__options">
<Card withBorder radius="lg" shadow="sm" padding="lg" className="tool-panel-mode-prompt__card tool-panel-mode-prompt__card--sidebar">
<Stack gap="md" className="tool-panel-mode-prompt__card-content">
<Group justify="space-between">
<Stack gap={2}>
<Text fw={600}>{t('toolPanel.modePrompt.sidebarTitle', 'Sidebar mode')}</Text>
<Text size="sm" c="dimmed">
{t('toolPanel.modePrompt.sidebarDescription', 'Keep tools alongside your workspace for quick switching.')}
</Text>
</Stack>
<Badge color="blue" variant="filled">
{t('toolPanel.modePrompt.recommended', 'Recommended')}
</Badge>
</Group>
<div className="tool-panel-mode-prompt__preview tool-panel-mode-prompt__preview--sidebar" aria-hidden>
<div className="tool-panel-mode-prompt__sidebar-panel">
<span className="tool-panel-mode-prompt__sidebar-search" />
<span className="tool-panel-mode-prompt__sidebar-item" />
<span className="tool-panel-mode-prompt__sidebar-item" />
<span className="tool-panel-mode-prompt__sidebar-item" />
<span className="tool-panel-mode-prompt__sidebar-item tool-panel-mode-prompt__sidebar-item--muted" />
</div>
<div className="tool-panel-mode-prompt__workspace" aria-hidden>
<div className="tool-panel-mode-prompt__workspace-page" />
<div className="tool-panel-mode-prompt__workspace-page tool-panel-mode-prompt__workspace-page--secondary" />
</div>
</div>
<Button
variant={toolPanelMode === 'sidebar' ? 'filled' : 'light'}
color="blue"
radius="md"
className="tool-panel-mode-prompt__action"
onClick={() => handleSelect('sidebar')}
>
{t('toolPanel.modePrompt.chooseSidebar', 'Use sidebar mode')}
</Button>
</Stack>
</Card>
<Card withBorder radius="lg" shadow="xs" padding="lg" className="tool-panel-mode-prompt__card">
<Stack gap="md" className="tool-panel-mode-prompt__card-content">
<Stack gap={2}>
<Text fw={600}>{t('toolPanel.modePrompt.fullscreenTitle', 'Fullscreen mode')}</Text>
<Text size="sm" c="dimmed">
{t('toolPanel.modePrompt.fullscreenDescription', 'Browse every tool in a catalogue that covers the workspace until you pick one.')}
</Text>
</Stack>
<div className="tool-panel-mode-prompt__preview tool-panel-mode-prompt__preview--fullscreen" aria-hidden>
<div className="tool-panel-mode-prompt__fullscreen-columns">
<div className="tool-panel-mode-prompt__fullscreen-column">
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card tool-panel-mode-prompt__fullscreen-card--muted" />
</div>
<div className="tool-panel-mode-prompt__fullscreen-column">
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card tool-panel-mode-prompt__fullscreen-card--muted" />
</div>
<div className="tool-panel-mode-prompt__fullscreen-column">
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card" />
<span className="tool-panel-mode-prompt__fullscreen-card tool-panel-mode-prompt__fullscreen-card--muted" />
</div>
</div>
</div>
<Button
variant={toolPanelMode === 'fullscreen' ? 'filled' : 'outline'}
color="blue"
radius="md"
className="tool-panel-mode-prompt__action"
onClick={() => handleSelect('fullscreen')}
>
{t('toolPanel.modePrompt.chooseFullscreen', 'Use fullscreen mode')}
</Button>
</Stack>
</Card>
</div>
<Button
variant="subtle"
color="gray"
radius="md"
className="tool-panel-mode-prompt__maybe-later"
onClick={handleDismiss}
>
{t('toolPanel.modePrompt.dismiss', 'Maybe later')}
</Button>
</Stack>
</Modal>
);
};
export default ToolPanelModePrompt;
+65 -31
View File
@@ -4,13 +4,20 @@ import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
import "./toolPicker/ToolPicker.css";
import { useToolSections } from "../../hooks/useToolSections";
import type { SubcategoryGroup } from "../../hooks/useToolSections";
import { useFavoriteToolItems } from "../../hooks/tools/useFavoriteToolItems";
import NoToolsFound from "./shared/NoToolsFound";
import { renderToolButtons } from "./shared/renderToolButtons";
import Badge from "../shared/Badge";
import SubcategoryHeader from "./shared/SubcategoryHeader";
import ToolButton from "./toolPicker/ToolButton";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { ToolId } from "../../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;
}
@@ -61,11 +68,23 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
}, []);
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const quickSection = useMemo(
() => visibleSections.find(s => s.key === 'quick'),
[visibleSections]
);
const recommendedItems = useMemo(() => {
if (!quickSection) return [] as Array<{ id: string; tool: ToolRegistryEntry }>;
const items: Array<{ id: string; tool: ToolRegistryEntry }> = [];
quickSection.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((toolEntry) => items.push(toolEntry)));
return items;
}, [quickSection]);
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
const allSection = useMemo(
() => visibleSections.find(s => s.key === 'all'),
[visibleSections]
@@ -87,7 +106,9 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
};
// Build flat list by subcategory for search mode
const { searchGroups } = useToolSections(isSearching ? filteredTools : []);
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
return (
<Box
@@ -115,7 +136,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
{searchGroups.length === 0 ? (
<NoToolsFound />
) : (
searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect))
searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect, true, false, filteredTools, true))
)}
</Stack>
) : (
@@ -142,24 +163,46 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
onClick={() => scrollTo(quickAccessRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: ".5rem",
padding: "0.125rem 0.5rem",
fontSize: ".75rem",
fontWeight: 700
}}
>
{quickSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
<Badge>
{recommendedCount}
</Badge>
</div>
<Box ref={quickAccessRef} w="100%" my="sm">
<Stack p="sm" gap="xs">
{quickSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, false)
{favoriteToolItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
</Stack>
</Box>
@@ -188,25 +231,16 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
onClick={() => scrollTo(allToolsRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
color: "var(--tool-header-badge-text)",
borderRadius: ".5rem",
padding: "0.125rem 0.5rem",
fontSize: ".75rem",
fontWeight: 700
}}
>
<Badge>
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</span>
</Badge>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true)
)}
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
)}
</Stack>
</Box>
</>
+10 -2
View File
@@ -1,10 +1,11 @@
import { Suspense } from "react";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { BaseToolProps } from "../../types/tool";
import { ToolId } from "../../types/toolId";
import ToolLoadingFallback from "./ToolLoadingFallback";
interface ToolRendererProps extends BaseToolProps {
selectedToolKey: string;
selectedToolKey: ToolId;
}
@@ -16,7 +17,14 @@ const ToolRenderer = ({
}: ToolRendererProps) => {
// Get the tool from context (instead of direct hook call)
const { toolRegistry } = useToolWorkflow();
const selectedTool = toolRegistry[selectedToolKey];
const selectedTool = (selectedToolKey in toolRegistry)
? toolRegistry[selectedToolKey as ToolId]
: undefined;
// Handle tools that only work in workbenches (read, multiTool)
if (selectedTool && !selectedTool.component && selectedTool.workbench) {
return null; // These tools render in their workbench, not in the sidebar
}
if (!selectedTool || !selectedTool.component) {
return <div>Tool not found: {selectedToolKey}</div>;
@@ -10,6 +10,7 @@ import {
Divider,
Modal
} from '@mantine/core';
import { Z_INDEX_AUTOMATE_MODAL } from '../../../styles/zIndex';
import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal';
@@ -221,6 +222,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
onClose={handleCancelBack}
title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')}
centered
zIndex={Z_INDEX_AUTOMATE_MODAL}
>
<Stack gap="md">
<Text>
@@ -8,6 +8,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip';
import { ToolIcon } from '../../shared/ToolIcon';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from 'src/types/toolId';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
@@ -31,7 +32,7 @@ interface AutomationEntryProps {
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<string, ToolRegistryEntry>;
toolRegistry?: Record<ToolId, ToolRegistryEntry>;
}
export default function AutomationEntry({
@@ -55,8 +56,8 @@ export default function AutomationEntry({
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation]?.name) {
return toolRegistry[operation].name;
if (toolRegistry?.[operation as ToolId]?.name) {
return toolRegistry[operation as ToolId].name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
@@ -30,7 +30,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
React.useEffect(() => {
useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
@@ -47,7 +47,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
}, [automation, toolRegistry]);
// Cleanup when component unmounts
React.useEffect(() => {
useEffect(() => {
return () => {
// Reset progress state when component unmounts
setExecutionSteps([]);
@@ -7,6 +7,7 @@ import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSugges
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
import { iconMap } from './iconMap';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
@@ -15,7 +16,7 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
}
export default function AutomationSelection({
@@ -9,6 +9,7 @@ import {
Text,
Alert
} from '@mantine/core';
import { Z_INDEX_AUTOMATE_MODAL } from '../../../styles/zIndex';
import SettingsIcon from '@mui/icons-material/Settings';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
@@ -103,6 +104,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
}
size="lg"
centered
zIndex={Z_INDEX_AUTOMATE_MODAL}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
@@ -6,12 +6,13 @@ import CloseIcon from "@mui/icons-material/Close";
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import { AutomationTool } from "../../../types/automation";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { ToolId } from "../../../types/toolId";
import ToolSelector from "./ToolSelector";
import AutomationEntry from "./AutomationEntry";
interface ToolListProps {
tools: AutomationTool[];
toolRegistry: Record<string, ToolRegistryEntry>;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
onToolRemove: (index: number) => void;
onToolConfigure: (index: number) => void;
@@ -34,7 +35,7 @@ export default function ToolList({
const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation);
const toolEntry = toolRegistry[newOperation];
const toolEntry = toolRegistry[newOperation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
@@ -6,11 +6,12 @@ import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
import ToolButton from '../toolPicker/ToolButton';
import { ToolId } from '../../../types/toolId';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
excludeTools?: string[];
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
toolRegistry: Record<ToolId, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
selectedValue?: string; // For showing current selection when editing existing tool
placeholder?: string; // Custom placeholder text
}
@@ -30,7 +31,7 @@ export default function ToolSelector({
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
const baseFilteredTools = useMemo(() => {
return Object.entries(toolRegistry).filter(([key, tool]) =>
return (Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]).filter(([key, tool]) =>
!excludeTools.includes(key) && getToolSupportsAutomate(tool)
);
}, [toolRegistry, excludeTools]);
@@ -53,20 +54,20 @@ export default function ToolSelector({
// Create filtered tool registry for ToolSearch
const filteredToolRegistry = useMemo(() => {
const registry: Record<string, ToolRegistryEntry> = {};
const registry: Record<ToolId, ToolRegistryEntry> = {} as Record<ToolId, ToolRegistryEntry>;
baseFilteredTools.forEach(([key, tool]) => {
registry[key] = tool;
registry[key as ToolId] = tool;
});
return registry;
}, [baseFilteredTools]);
// Transform filteredTools to the expected format for useToolSections
const transformedFilteredTools = useMemo(() => {
return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
return filteredTools.map(([id, tool]) => ({ item: [id as ToolId, tool] as [ToolId, ToolRegistryEntry] }));
}, [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;
@@ -88,7 +89,7 @@ export default function ToolSelector({
}
// Find the "all" section which contains all tools without duplicates
const allSection = sections.find(s => (s as any).key === 'all');
const allSection = sections.find(s => s.key === 'all');
return allSection?.subcategories || [];
}, [isSearching, searchGroups, sections, baseFilteredTools]);
@@ -100,7 +101,7 @@ export default function ToolSelector({
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching, true)
renderToolButtons(t, subcategory as any, null, handleToolSelect, !isSearching, true)
), [displayGroups, handleToolSelect, isSearching, t]
);
@@ -142,8 +143,8 @@ export default function ToolSelector({
// Get display value for selected tool
const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue]) {
return toolRegistry[selectedValue].name;
if (selectedValue && toolRegistry[selectedValue as ToolId]) {
return toolRegistry[selectedValue as ToolId].name;
}
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
};
@@ -152,11 +153,11 @@ export default function ToolSelector({
<div ref={containerRef} className='rounded-xl'>
{/* Always show the target - either selected tool or search input */}
{selectedValue && toolRegistry[selectedValue] && !opened ? (
{selectedValue && toolRegistry[selectedValue as ToolId] && !opened ? (
// 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 ToolId} tool={toolRegistry[selectedValue as ToolId]} isSelected={false}
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
</div>
) : (
@@ -0,0 +1,108 @@
import React from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '../../shared/Tooltip';
import HotkeyDisplay from '../../hotkeys/HotkeyDisplay';
import FavoriteStar from '../toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared';
interface CompactToolItemProps {
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onClick: () => void;
tooltipPortalTarget?: HTMLElement | undefined;
}
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, false);
const iconClasses = 'tool-panel__fullscreen-list-icon';
let iconNode: React.ReactNode = null;
if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) {
const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>;
iconNode = React.cloneElement(element, {
style: {
...(element.props.style || {}),
fontSize: '1.5rem',
},
});
} else {
iconNode = tool.icon;
}
const compactButton = (
<button
type="button"
className={`tool-panel__fullscreen-list-item ${getItemClasses(false)} ${isSelected ? 'tool-panel__fullscreen-list-item--selected' : ''} ${!disabled ? 'tool-panel__fullscreen-list-item--with-star' : ''}`}
onClick={onClick}
aria-disabled={disabled}
disabled={disabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-list-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
</span>
{!disabled && (
<div className="tool-panel__fullscreen-star-compact">
<FavoriteStar
isFavorite={isFav}
onToggle={toggleFavorite}
size="xs"
/>
</div>
)}
</button>
);
const tooltipContent = disabled
? (
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
{binding && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} />
</div>
)}
</div>
);
return (
<Tooltip
content={tooltipContent}
position="top"
portalTarget={tooltipPortalTarget}
arrow
delay={80}
>
{compactButton}
</Tooltip>
);
};
export default CompactToolItem;
@@ -0,0 +1,88 @@
import React from 'react';
import { Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import HotkeyDisplay from '../../hotkeys/HotkeyDisplay';
import FavoriteStar from '../toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '../../../data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from './shared';
interface DetailedToolItemProps {
id: string;
tool: ToolRegistryEntry;
isSelected: boolean;
onClick: () => void;
}
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, true);
const iconClasses = 'tool-panel__fullscreen-icon';
let iconNode: React.ReactNode = null;
if (React.isValidElement<{ style?: React.CSSProperties }>(tool.icon)) {
const element = tool.icon as React.ReactElement<{ style?: React.CSSProperties }>;
iconNode = React.cloneElement(element, {
style: {
...(element.props.style || {}),
fontSize: '1.75rem',
},
});
} else {
iconNode = tool.icon;
}
return (
<button
type="button"
className={`tool-panel__fullscreen-item ${getItemClasses(true)} ${isSelected ? 'tool-panel__fullscreen-item--selected' : ''} tool-panel__fullscreen-item--with-star`}
onClick={onClick}
aria-disabled={disabled}
disabled={disabled}
>
{tool.icon ? (
<span
className={iconClasses}
aria-hidden
style={{
background: iconBg,
...getIconStyle(),
}}
>
{iconNode}
</span>
) : null}
<span className="tool-panel__fullscreen-body">
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
{tool.name}
</Text>
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
{tool.description}
</Text>
{binding && (
<div className="tool-panel__fullscreen-shortcut">
<span style={{ color: 'var(--mantine-color-dimmed)', fontSize: '0.75rem' }}>
{t('settings.hotkeys.shortcut', 'Shortcut')}
</span>
<HotkeyDisplay binding={binding} size="sm" />
</div>
)}
</span>
{!disabled && (
<div className="tool-panel__fullscreen-star">
<FavoriteStar
isFavorite={isFav}
onToggle={toggleFavorite}
size="sm"
/>
</div>
)}
</button>
);
};
export default DetailedToolItem;
@@ -0,0 +1,45 @@
import { useHotkeys } from '../../../contexts/HotkeyContext';
import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
export const getItemClasses = (isDetailed: boolean): string => {
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
};
export const getIconBackground = (categoryColor: string, isDetailed: boolean): string => {
const baseColor = isDetailed ? 'var(--fullscreen-bg-icon-detailed)' : 'var(--fullscreen-bg-icon-compact)';
const blend1 = isDetailed ? '18%' : '15%';
const blend2 = isDetailed ? '8%' : '6%';
return `linear-gradient(135deg,
color-mix(in srgb, ${categoryColor} ${blend1}, ${baseColor}),
color-mix(in srgb, ${categoryColor} ${blend2}, ${baseColor})
)`;
};
export const getIconStyle = (): Record<string, string> => {
return {};
};
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
};
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
const { hotkeys } = useHotkeys();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const isFav = isFavorite(id as ToolId);
const binding = hotkeys[id as ToolId];
const disabled = isToolDisabled(id, tool);
return {
binding,
isFav,
toggleFavorite: () => toggleFavorite(id as ToolId),
disabled,
};
}
@@ -0,0 +1,50 @@
.fileListContainer {
max-height: 16.25rem;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
}
.fileItem {
border: 1px solid var(--mantine-color-gray-3);
border-radius: var(--mantine-radius-sm);
align-items: center;
width: 100%;
}
.fileNameContainer {
flex: 1;
min-width: 0;
}
.fileName {
font-size: var(--mantine-font-size-sm);
font-weight: 400;
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
word-break: break-word;
}
.fileSize {
flex-shrink: 0;
}
.removeButton {
flex-shrink: 0;
}
.countLabel {
width: 140px;
flex-shrink: 0;
}
.fileGroup {
flex: 1;
min-width: 0;
align-items: center;
}
@@ -0,0 +1,178 @@
import { Stack, Text, Group, Select, SegmentedControl, NumberInput, Button, ActionIcon, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { type OverlayPdfsParameters, type OverlayMode } from '../../../hooks/tools/overlayPdfs/useOverlayPdfsParameters';
import LocalIcon from '../../shared/LocalIcon';
import { useFilesModalContext } from '../../../contexts/FilesModalContext';
import styles from './OverlayPdfsSettings.module.css';
interface OverlayPdfsSettingsProps {
parameters: OverlayPdfsParameters;
onParameterChange: <K extends keyof OverlayPdfsParameters>(key: K, value: OverlayPdfsParameters[K]) => void;
disabled?: boolean;
}
export default function OverlayPdfsSettings({ parameters, onParameterChange, disabled = false }: OverlayPdfsSettingsProps) {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const handleOverlayFilesChange = (files: File[]) => {
onParameterChange('overlayFiles', files);
// Reset counts to match number of files if in FixedRepeatOverlay
if (parameters.overlayMode === 'FixedRepeatOverlay') {
const nextCounts = files.map((_, i) => parameters.counts[i] && parameters.counts[i] > 0 ? parameters.counts[i] : 1);
onParameterChange('counts', nextCounts);
}
};
const handleModeChange = (mode: OverlayMode) => {
onParameterChange('overlayMode', mode);
if (mode !== 'FixedRepeatOverlay') {
onParameterChange('counts', []);
} else if (parameters.overlayFiles?.length > 0) {
onParameterChange('counts', parameters.overlayFiles.map((_, i) => parameters.counts[i] && parameters.counts[i] > 0 ? parameters.counts[i] : 1));
}
};
const handleOpenOverlayFilesModal = () => {
if (disabled) return;
openFilesModal({
customHandler: (files: File[]) => {
handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]);
}
});
};
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.mode.label', 'Overlay Mode')}</Text>
<Select
data={[
{ value: 'SequentialOverlay', label: t('overlay-pdfs.mode.sequential', 'Sequential Overlay') },
{ value: 'InterleavedOverlay', label: t('overlay-pdfs.mode.interleaved', 'Interleaved Overlay') },
{ value: 'FixedRepeatOverlay', label: t('overlay-pdfs.mode.fixedRepeat', 'Fixed Repeat Overlay') },
]}
value={parameters.overlayMode}
onChange={(v) => handleModeChange((v || 'SequentialOverlay') as OverlayMode)}
disabled={disabled}
/>
</Stack>
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.position.label', 'Overlay Position')}</Text>
<SegmentedControl
value={String(parameters.overlayPosition)}
onChange={(v) => onParameterChange('overlayPosition', (v === '1' ? 1 : 0) as 0 | 1)}
data={[
{ label: t('overlay-pdfs.position.foreground', 'Foreground'), value: '0' },
{ label: t('overlay-pdfs.position.background', 'Background'), value: '1' },
]}
disabled={disabled}
/>
</Stack>
{parameters.overlayMode === 'FixedRepeatOverlay' && (
<>
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.counts.label', 'Overlay Counts')}</Text>
{parameters.overlayFiles?.length > 0 ? (
<Stack gap="xs">
{parameters.overlayFiles.map((_, index) => (
<Group key={index} gap="xs" wrap="nowrap">
<Text size="sm" className={styles.countLabel}>
{t('overlay-pdfs.counts.item', 'Count for file')} {index + 1}
</Text>
<NumberInput
min={1}
step={1}
value={parameters.counts[index] ?? 1}
onChange={(value) => {
const next = [...(parameters.counts || [])];
next[index] = Number(value) || 1;
onParameterChange('counts', next);
}}
disabled={disabled}
/>
</Group>
))}
</Stack>
) : (
<Text size="sm" c="dimmed">
{t('overlay-pdfs.counts.noFiles', 'Add overlay files to configure counts')}
</Text>
)}
</Stack>
</>
)}
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('overlay-pdfs.overlayFiles.label', 'Overlay Files')}</Text>
<Button
size="xs"
color="blue"
onClick={handleOpenOverlayFilesModal}
disabled={disabled}
leftSection={<LocalIcon icon="add" width="14" height="14" />}
fullWidth
>
{parameters.overlayFiles?.length > 0
? t('overlay-pdfs.overlayFiles.addMore', 'Add more PDFs...')
: t('overlay-pdfs.overlayFiles.placeholder', 'Choose PDF(s)...')}
</Button>
{parameters.overlayFiles?.length > 0 && (() => {
return (
<div className={styles.fileListContainer}>
<Stack gap="xs">
{parameters.overlayFiles.map((file, index) => (
<Group
key={index}
justify="space-between"
p="xs"
className={styles.fileItem}
>
<Group gap="xs" className={styles.fileGroup}>
<div className={styles.fileNameContainer}>
<div
className={styles.fileName}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" className={styles.fileSize}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
className={styles.removeButton}
onClick={() => {
const next = (parameters.overlayFiles || []).filter((_, i) => i !== index);
handleOverlayFilesChange(next);
}}
disabled={disabled}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</div>
);
})()}
</Stack>
</Stack>
);
}
@@ -5,16 +5,18 @@ 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 }>
searchResults?: Array<{ item: [string, any]; matchedText?: string }>,
hasStars: boolean = false
) => {
// Create a map of matched text for quick lookup
const matchedTextMap = new Map<string, string>();
@@ -32,7 +34,7 @@ export const renderToolButtons = (
<div>
{subcategory.tools.map(({ id, tool }) => {
const matchedSynonym = matchedTextMap.get(id);
return (
<ToolButton
key={id}
@@ -42,6 +44,7 @@ export const renderToolButtons = (
onSelect={onSelect}
disableNavigation={disableNavigation}
matchedSynonym={matchedSynonym}
hasStars={hasStars}
/>
);
})}

Some files were not shown because too many files have changed in this diff Show More