Compare commits

..
4 Commits
Author SHA1 Message Date
Anthony Stirling 7eafe68e58 progress 2025-10-12 14:06:13 +01:00
stirlingbot[bot] 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
EthanHealy01 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
114 changed files with 5612 additions and 1001 deletions
+30 -30
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) |
| 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) | ![95%](https://geps.dev/progress/95) |
| 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) |
| 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) |
| 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) | ![98%](https://geps.dev/progress/98) |
| Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) |
| 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);
}
/**
@@ -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();
}
}
@@ -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
@@ -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,
@@ -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
@@ -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);
+247 -239
View File
@@ -8,15 +8,14 @@ Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This
Stirling-PDF is built using:
- Spring Boot (Backend API)
- React + TypeScript + Vite (Frontend V2)
- Mantine UI + TailwindCSS (UI Framework)
- PDFBox (PDF manipulation)
- LibreOffice (Document conversion)
- qpdf (PDF processing)
- PDF.js (Client-side PDF rendering)
- Embedded-PDF (PDF viewer component)
- Spring Boot + Thymeleaf
- PDFBox
- LibreOffice
- qpdf
- HTML, CSS, JavaScript
- Docker
- PDF.js
- PDF-LIB.js
- Lombok
## 3. Development Environment Setup
@@ -25,9 +24,8 @@ Stirling-PDF is built using:
- Docker
- Git
- Java JDK 17 or later (JDK 21 recommended)
- Java JDK 17 or later
- Gradle 7.0 or later (Included within the repo)
- Node.js 18+ and npm (for frontend development)
### Setup Steps
@@ -40,8 +38,8 @@ Stirling-PDF is built using:
2. Install Docker and JDK17 if not already installed.
3. Install a recommended IDE:
- **VSCode** (recommended for frontend)
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
1. Open VS Code.
2. When prompted, install the recommended extensions.
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
@@ -51,15 +49,13 @@ Stirling-PDF is built using:
```
4. Install the required extensions from the list.
- **IntelliJ IDEA** (recommended for backend)
- **Eclipse** (alternative for backend)
4. Lombok Setup
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
## 4. Project Structure
@@ -72,21 +68,10 @@ Stirling-PDF/
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
├── frontend/ # React frontend application (V2)
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── tools/ # PDF tool implementations
│ │ ├── contexts/ # React contexts (FileContext, etc.)
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API and processing services
│ │ └── i18n.ts # Internationalization config
│ ├── public/
│ │ └── locales/ # Translation JSON files
│ └── package.json
├── images/ # Image assets
├── pipeline/ # Pipeline-related files (generated at runtime)
├── scripts/ # Utility scripts
├── src/ # Backend source code
├── src/ # Source code
│ ├── main/
│ │ ├── java/
│ │ │ └── stirling/
@@ -94,14 +79,16 @@ Stirling-PDF/
│ │ │ └── SPDF/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ │ ├── api/ # REST API endpoints
│ │ │ │ └── web/ # Web controllers
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ ── static/ # Legacy static assets
│ │ ── static/
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/
│ └── test/
│ └── java/
│ └── stirling/
@@ -154,7 +141,7 @@ services:
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
environment:
DOCKER_ENABLE_SECURITY: "true"
DISABLE_ADDITIONAL_FEATURES: "false"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
@@ -183,7 +170,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DOCKER_ENABLE_SECURITY=true # or false to disable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
```
2. Build the project with Gradle:
@@ -209,7 +196,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
For the fat version (with login and security features enabled):
```bash
export DOCKER_ENABLE_SECURITY=true
export DISABLE_ADDITIONAL_FEATURES=false
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
@@ -237,50 +224,38 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
### Full Testing with Docker
1. Build and run the Docker container per the above instructions
1. Build and run the Docker container per the above instructions:
2. Access the application at `http://localhost:8080` and manually test all features developed.
### Local Testing (Frontend and Backend)
### Local Testing (Java and UI Components)
For quick iterations and development, you can run the frontend and backend separately:
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
#### Backend Development
- Java backend logic
- RESTful API endpoints
- JavaScript functionality
- User interface components and styling
- Thymeleaf templates
1. Run the backend:
To run Stirling-PDF locally:
1. Compile and run the project using built-in IDE methods or by running:
```bash
./gradlew bootRun
```
2. The backend API will be available at `http://localhost:8080`
2. Access the application at `http://localhost:8080` in your web browser.
3. API documentation is available at `http://localhost:8080/swagger-ui/index.html`
3. Manually test the features you're working on through the UI.
#### Frontend Development
1. Install dependencies (first time only):
```bash
cd frontend
npm install
```
2. Start the development server:
```bash
npm run dev
```
3. The frontend will be available at `http://localhost:5173`
4. Vite automatically proxies API calls from `/api/*` to the backend at `localhost:8080`
4. For API changes, use tools like Postman or curl to test endpoints directly.
Important notes:
- Frontend requires the backend to be running for full functionality
- Hot module replacement (HMR) enables instant updates during development
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
## 7. Contributing
@@ -332,170 +307,112 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
Refer to the main README for a full list of customization options.
## 10. Frontend Development (V2)
## 10. Language Translations
### Architecture Overview
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
The V2 frontend is designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
### Key Components
#### FileContext - Central State Management
**Location**: `frontend/src/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
- **IndexedDB persistence**: File storage with thumbnail caching
- **Preview system**: Tools can preview results without context pollution
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
#### Processing Services
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
- **fileStorage**: IndexedDB with LRU cache management
### Tool Development
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
});
```bash
/scripts/replace_translation_line.sh
```
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
This script helps you make consistent replacements across language files.
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_',
});
When contributing translations:
1. Use the helper script for multi-file changes.
2. Ensure all language files are updated consistently.
3. The PR checks will verify consistency in language file updates.
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
## Code examples
### Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `app/core/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
});
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
### Adding a New Tool
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
See [ADDING_TOOLS.md](../ADDING_TOOLS.md) for a complete guide to creating new PDF tools.
### Internationalization
Translations are stored in JSON files at `frontend/public/locales/{language-code}/translation.json`.
To use translations in React components:
```typescript
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return (
<div>
<h1>{t('myTool.title')}</h1>
<p>{t('myTool.description')}</p>
</div>
);
}
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
```
See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new languages.
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
## 11. Backend Development
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
### Adding a New API Endpoint
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API)
1. **Create a New Controller:**
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/api` directory.
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag` and `@Operation`.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/pdf")
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@PostMapping("/new-feature")
@Operation(summary = "New Feature", description = "This is a new feature endpoint. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> newFeature(
@RequestPart("fileInput") MultipartFile file,
@RequestParam("param1") String param1) {
// Process PDF
byte[] result = processFile(file, param1);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=output.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(result);
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
}
}
```
2. **Define the Service Layer:** (Optional but recommended)
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
2. **Define the Service Layer:** (Not required but often useful)
- Create a new service class in the `app/core/src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
@@ -506,76 +423,167 @@ See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new
@Service
public class NewFeatureService {
public byte[] processFile(MultipartFile file, String param1) {
public String getNewFeatureData() {
// Implement business logic here
return processedBytes;
return "New Feature Data";
}
}
```
3. **Integrate the Service with the Controller:**
2b. **Integrate the Service with the Controller:**
- Autowire the service class in the controller and use it to handle the API request.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.SPDF.service.NewFeatureService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return newFeatureService.getNewFeatureData();
}
}
```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `app/core/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
```java
@RestController
@RequestMapping("/api/v1/pdf")
public class NewFeatureController {
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@Autowired
private NewFeatureService newFeatureService;
@PostMapping("/new-feature")
public ResponseEntity<byte[]> newFeature(
@RequestPart("fileInput") MultipartFile file,
@RequestParam("param1") String param1) {
byte[] result = newFeatureService.processFile(file, param1);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=output.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(result);
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
}
}
```
### Multi-File Endpoints
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `app/core/src/main/resources/templates/fragments/navbar.html` file.
For tools that process multiple files in one request:
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
```java
@PostMapping("/merge")
public ResponseEntity<byte[]> mergePdfs(
@RequestPart("fileInput") MultipartFile[] files) {
## Adding New Translations to Existing Language Files in Stirling-PDF
// Process all files together
byte[] merged = mergeService.mergeFiles(files);
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=merged.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(merged);
}
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `app/core/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
### 2. Add New Translation Entries
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
you might add:
```properties
pdfSplitter.title=PDF Splitter
pdfSplitter.description=Split your PDF into multiple documents
pdfSplitter.button.split=Split PDF
pdfSplitter.input.pages=Enter page numbers to split
```
## 12. Best Practices
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
### Frontend
- Always use FileContext for file operations
- Implement proper cleanup for PDF.js documents and blob URLs
- Use the `useToolOperation` hook for consistent tool behavior
- Follow TypeScript strict mode guidelines
- Test with large files (100MB+) to ensure memory efficiency
### 3. Use Translations in Thymeleaf Templates
### Backend
- Use PDFBox for PDF manipulation
- Implement proper error handling and logging
- Add Swagger documentation to all API endpoints
- Use service layer for business logic
- Follow Spring Boot best practices
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
### General
- Write clear commit messages
- Update documentation for any API changes
- Test in Docker before submitting PRs
- Run `./gradlew spotlessApply` to format code
- Ensure all tests pass with `./test.sh`
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
+27 -149
View File
@@ -8,66 +8,36 @@
Fork Stirling-PDF and create a new branch out of `main`.
## Add Language to i18n Configuration
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
Edit the file: [frontend/src/i18n.ts](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/src/i18n.ts)
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
Add your language to the `supportedLanguages` object. For example, to add Polish:
```typescript
export const supportedLanguages = {
'en': 'English',
'en-GB': 'English (UK)',
// ... other languages ...
'pl-PL': 'Polski', // Add your language here
};
For example, to add Polish, you would add:
```html
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
```
If your language uses right-to-left (RTL) text direction, also add it to the `rtlLanguages` array:
The `data-bs-language-code` is the code used to reference the file in the next step.
```typescript
export const rtlLanguages = ['ar-AR', 'fa-IR', 'pl-PL']; // Add if RTL
```
### Add Language Property File
## Create Translation Directory
Start by copying the existing English property file:
Create a new directory for your language in `frontend/public/locales/`. For Polish, this would be:
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
```bash
mkdir -p frontend/public/locales/pl-PL
```
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
## Add Translation File
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
Start by copying the existing English (UK) translation file:
- [frontend/public/locales/en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.json)
Copy and rename it to `frontend/public/locales/{your-language-code}/translation.json`. In the Polish example:
```bash
cp frontend/public/locales/en-GB/translation.json frontend/public/locales/pl-PL/translation.json
```
Then translate all entries within that JSON file. The file uses nested JSON structure like:
```json
{
"addPageNumbers": {
"title": "Add Page Numbers",
"submit": "Add Page Numbers",
"error": {
"failed": "Add page numbers operation failed"
}
}
}
```
If you do not have a Java IDE, I am happy to verify that the changes work once you raise the PR (but I won't be able to verify the translations themselves).
## Handling Untranslatable Strings
Sometimes, certain strings may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
Sometimes, certain strings in the properties file may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
For example, if the English string for "error" does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
For example, if the English string `error=Error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
```toml
[pl_PL]
@@ -80,119 +50,27 @@ ignore = [
## Add New Translation Tags
> [!IMPORTANT]
> If you add any new translation tags, they must first be added to the `frontend/public/locales/en-GB/translation.json` file. This ensures consistency across all language files.
> If you add any new translation tags, they must first be added to the `messages_en_GB.properties` file. This ensures consistency across all language files.
- New translation tags **must be added** to the `en-GB` translation file to maintain a reference for other languages.
- After adding the new tags to the en-GB file, add and translate them in the respective language file (e.g., `pl-PL/translation.json`).
- New translation tags **must be added** to the `messages_en_GB.properties` file to maintain a reference for other languages.
- After adding the new tags to `messages_en_GB.properties`, add and translate them in the respective language file (e.g., `messages_pl_PL.properties`).
Make sure to place the entry under the correct language section. This helps maintain the accuracy of translation progress statistics and ensures that the translation tool or scripts do not misinterpret the completion rate.
## Testing Your Translation
### Use this code to perform a local check
### Start the development server
#### Windows command
1. Start the frontend development server:
```bash
cd frontend
npm run dev
```
```powershell
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
2. The language selector should now include your new language
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
```
3. Select your language from the dropdown and verify all translations appear correctly
## Summary Checklist
When adding a new language, you need to update:
- [ ] `frontend/src/i18n.ts` - Add to supportedLanguages (and rtlLanguages if needed)
- [ ] `frontend/public/locales/{language-code}/translation.json` - Create and translate
- [ ] `scripts/ignore_translation.toml` - Add untranslatable strings if needed
Then make a Pull Request (PR) into `main` for others to use!
If you do not have a Node.js environment, we are happy to verify that the changes work once you raise the PR (but we won't be able to verify the translations themselves).
## Translation Guidelines
- **Consistency**: Keep terminology consistent throughout the translation
- **Context**: Consider the UI context when translating (e.g., button labels should be concise)
- **Formatting**: Preserve placeholders like `{n}` or `{{count}}` in translations
- **Testing**: Test your translations in the frontend interface
- **RTL Languages**: If your language uses RTL, ensure you add it to the rtlLanguages array
## Advanced: Translation Management Scripts
For translators working on large translation files, Python scripts are available in `scripts/translations/` to help manage the workflow.
### Finding Untranslated Strings
To see which strings still need translation:
#### Linux command
```bash
# Check translation status for your language
python scripts/translations/translation_analyzer.py --language pl-PL --summary
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
# See detailed list of missing translations
python scripts/translations/translation_analyzer.py --language pl-PL --missing-only
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
```
### Extracting Untranslated Strings
To extract only the strings that need translation into a separate file:
```bash
# Extract to a compact JSON file
python scripts/translations/compact_translator.py pl-PL --output to_translate.json
```
This creates a file with just the untranslated entries:
```json
{
"addPageNumbers.title": "Add Page Numbers",
"compress.header": "Compress PDF",
"merge.submit": "Merge PDFs"
}
```
### Translating the Extracted File
Open `to_translate.json` and translate the values while keeping the keys unchanged:
```json
{
"addPageNumbers.title": "Dodaj numery stron",
"compress.header": "Kompresuj PDF",
"merge.submit": "Połącz pliki PDF"
}
```
### Merging Translations Back
After translating, merge your translations back into the main file:
```bash
# Apply your translations
python scripts/translations/translation_merger.py pl-PL apply-translations --translations-file to_translate.json
# Verify the result
python scripts/translations/translation_analyzer.py --language pl-PL --summary
```
### Validating Your Work
Before submitting, validate your translation file:
```bash
# Check for JSON syntax errors
python scripts/translations/json_validator.py frontend/public/locales/pl-PL/translation.json
# Check for missing placeholders
python scripts/translations/validate_placeholders.py --language pl-PL
# Check for structural issues
python scripts/translations/validate_json_structure.py --language pl-PL
```
**Note**: These scripts require Python 3.7+ to be installed. See `scripts/translations/README.md` for detailed documentation.
+59 -8
View File
@@ -1,4 +1,21 @@
{
"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",
@@ -598,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",
@@ -2550,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",
@@ -2564,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",
+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",
+3
View File
@@ -9,6 +9,7 @@ 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';
@@ -87,6 +88,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
}, []);
// Preload Google Drive scripts if configured
useEffect(() => {
if (isGoogleDriveConfigured()) {
// Load scripts in parallel without blocking
@@ -125,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',
@@ -399,3 +399,101 @@
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,4 +1,4 @@
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';
@@ -62,7 +62,7 @@ const FileEditor = ({
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
React.useEffect(() => {
useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
@@ -55,6 +55,9 @@ const FileEditorThumbnail = ({
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);
@@ -213,6 +216,9 @@ const FileEditorThumbnail = ({
};
const progressContainerBottom = file.toolHistory ? '56px' : '12px';
const toolChainBottom = hasActiveJobs ? '8px' : '4px';
return (
<div
ref={fileElementRef}
@@ -436,6 +442,54 @@ const FileEditorThumbnail = ({
)}
</div>
{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) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
@@ -445,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>
@@ -40,7 +40,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
@@ -188,7 +188,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onAddToRecents(file);
}}
>
{t('fileManager.restore', 'Restore')}
@@ -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">
@@ -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,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core';
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;
@@ -26,6 +27,24 @@ const GeneralSection: React.FC = () => {
<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
@@ -1,11 +1,11 @@
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, HotkeyBinding } from '../../../../utils/hotkeys';
import { ToolId } from 'src/types/toolId';
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
const rowStyle: React.CSSProperties = {
@@ -28,9 +28,21 @@ const HotkeysSection: React.FC = () => {
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
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) {
return;
@@ -47,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';
@@ -71,7 +85,10 @@ const HotkeysSection: React.FC = () => {
));
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;
}
@@ -101,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;
@@ -160,10 +190,11 @@ const HotkeysSection: React.FC = () => {
)}
</Box>
{index < tools.length - 1 && <Divider />}
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})}
})
)}
</Stack>
</Paper>
</Stack>
@@ -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;
}
@@ -14,9 +14,10 @@ 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);
@@ -52,7 +53,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
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"
@@ -60,7 +61,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -104,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" />
@@ -129,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"
@@ -146,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" />
@@ -155,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"
@@ -203,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,13 +1,13 @@
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';
import SubcategoryHeader from './shared/SubcategoryHeader';
import NoToolsFound from './shared/NoToolsFound';
import "./toolPicker/ToolPicker.css";
import { ToolId } from 'src/types/toolId';
interface SearchResultsProps {
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
+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;
+64 -31
View File
@@ -4,9 +4,15 @@ 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 { ToolId } from "src/types/toolId";
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;
@@ -62,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]
@@ -88,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
@@ -116,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>
) : (
@@ -143,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>
@@ -189,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>
</>
@@ -1,8 +1,8 @@
import { Suspense } from "react";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { BaseToolProps } from "../../types/tool";
import { ToolId } from "../../types/toolId";
import ToolLoadingFallback from "./ToolLoadingFallback";
import { ToolId } from "src/types/toolId";
interface ToolRendererProps extends BaseToolProps {
selectedToolKey: ToolId;
@@ -17,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,12 +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 'src/types/toolId';
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
}
@@ -54,16 +54,16 @@ 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
@@ -89,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]);
@@ -101,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]
);
@@ -143,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...');
};
@@ -153,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' as any /* FIX ME */} 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>
);
}
@@ -15,7 +15,8 @@ export const renderToolButtons = (
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>();
@@ -43,6 +44,7 @@ export const renderToolButtons = (
onSelect={onSelect}
disableNavigation={disableNavigation}
matchedSynonym={matchedSynonym}
hasStars={hasStars}
/>
);
})}
@@ -0,0 +1,41 @@
import React from "react";
import { ActionIcon } from "@mantine/core";
import type { MantineSize } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import StarBorderRoundedIcon from '@mui/icons-material/StarBorderRounded';
interface FavoriteStarProps {
isFavorite: boolean;
onToggle: () => void;
className?: string;
size?: MantineSize;
}
const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, className, size = "xs" }) => {
const { t } = useTranslation();
return (
<ActionIcon
variant="subtle"
radius="xl"
size={size}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onToggle();
}}
className={className}
aria-label={isFavorite ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
>
{isFavorite ? (
<StarRoundedIcon fontSize="inherit" style={{ color: 'var(--special-color-favorites)', fontSize: '1rem' }} />
) : (
<StarBorderRoundedIcon fontSize="inherit" style={{ fontSize: '1rem' }} />
)}
</ActionIcon>
);
};
export default FavoriteStar;
@@ -9,7 +9,9 @@ import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
import { useHotkeys } from "../../../contexts/HotkeyContext";
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
import { ToolId } from "src/types/toolId";
import FavoriteStar from "./FavoriteStar";
import { useToolWorkflow } from "../../../contexts/ToolWorkflowContext";
import { ToolId } from "../../../types/toolId";
interface ToolButtonProps {
id: ToolId;
@@ -19,15 +21,18 @@ interface ToolButtonProps {
rounded?: boolean;
disableNavigation?: boolean;
matchedSynonym?: string;
hasStars?: boolean;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
const { t } = useTranslation();
// Special case: read and multiTool are navigational tools that are always available
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const { hotkeys } = useHotkeys();
const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const fav = isFavorite(id as ToolId);
const handleClick = (id: ToolId) => {
if (isUnavailable) return;
@@ -107,8 +112,12 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
@@ -128,8 +137,12 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
@@ -146,16 +159,36 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' }, label: { overflow: 'visible' } }}
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
cursor: isUnavailable ? 'not-allowed' : undefined,
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
{buttonContent}
</Button>
);
const star = hasStars && !isUnavailable ? (
<FavoriteStar
isFavorite={fav}
onToggle={() => toggleFavorite(id as ToolId)}
className="tool-button-star"
size="xs"
/>
) : null;
return (
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
{buttonElement}
</Tooltip>
<div className="tool-button-container">
{star}
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
{buttonElement}
</Tooltip>
</div>
);
};
@@ -71,6 +71,24 @@
line-height: 1;
}
/* Container to enable hover-only favourite star overlay */
.tool-button-container {
position: relative;
}
.tool-button-star {
position: absolute;
top: 0.35rem;
right: 0.35rem;
opacity: 0;
transition: opacity 0.2s ease;
z-index: var(--z-toolpicker-star); /* lower than sticky section headers */
}
.tool-button-container:hover .tool-button-star {
opacity: 1;
}
.search-input-container {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -6,12 +6,13 @@ import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { TextInput } from "../../shared/TextInput";
import "./ToolPicker.css";
import { rankByFuzzy, idToWords } from "../../../utils/fuzzySearch";
import { ToolId } from "src/types/toolId";
interface ToolSearchProps {
value: string;
onChange: (value: string) => void;
toolRegistry: Readonly<Record<string, ToolRegistryEntry>>;
onToolSelect?: (toolId: string) => void;
toolRegistry: Partial<Record<ToolId, ToolRegistryEntry>>;
onToolSelect?: (toolId: ToolId) => void;
mode: "filter" | "dropdown" | "unstyled";
selectedToolKey?: string | null;
placeholder?: string;
@@ -81,15 +82,15 @@ const ToolSearch = ({
}, [autoFocus]);
const searchInput = (
<TextInput
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off"
onFocus={onFocus}
/>
<TextInput
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <LocalIcon icon="search-rounded" width="1.5rem" height="1.5rem" />}
autoComplete="off"
onFocus={onFocus}
/>
);
if (mode === "filter") {
@@ -126,7 +127,7 @@ const ToolSearch = ({
key={id}
variant="subtle"
onClick={() => {
onToolSelect?.(id);
onToolSelect?.(id as ToolId);
setDropdownOpen(false);
}}
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const useOverlayPdfsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('overlay-pdfs.tooltip.header.title', 'Overlay PDFs Overview')
},
tips: [
{
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
description: t(
'overlay-pdfs.tooltip.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.'
)
},
{
title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'),
description: t(
'overlay-pdfs.tooltip.mode.text',
'Choose how to distribute overlay pages across the base PDF pages.'
),
bullets: [
t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'),
t('overlay-pdfs.tooltip.mode.interleaved', 'Interleaved Overlay: Take one page from each overlay in turn.'),
t('overlay-pdfs.tooltip.mode.fixedRepeat', 'Fixed Repeat Overlay: Take a set number of pages from each overlay before moving to the next. Use Counts to set the numbers.')
]
},
{
title: t('overlay-pdfs.tooltip.position.title', 'Overlay Position'),
description: t(
'overlay-pdfs.tooltip.position.text',
'Foreground places the overlay on top of the page. Background places it behind.'
)
},
{
title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'),
description: t(
'overlay-pdfs.tooltip.overlayFiles.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.'
)
},
{
title: t('overlay-pdfs.tooltip.counts.title', 'Counts (Fixed Repeat only)'),
description: t(
'overlay-pdfs.tooltip.counts.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.'
)
}
]
};
};
@@ -94,7 +94,7 @@ const EmbedPdfViewerContent = ({
}, [previewFile, fileWithUrl]);
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
React.useEffect(() => {
useEffect(() => {
let accumulator = 0;
const handleWheel = (event: WheelEvent) => {
@@ -128,7 +128,7 @@ const EmbedPdfViewerContent = ({
}, [zoomActions]);
// Handle keyboard zoom shortcuts
React.useEffect(() => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return;
@@ -0,0 +1,21 @@
// Central list of formats supported by Convert operations
export const CONVERT_SUPPORTED_FORMATS = [
// Microsoft Office
'doc', 'docx', 'dot', 'dotx', 'csv', 'xls', 'xlsx', 'xlt', 'xltx', 'slk', 'dif', 'ppt', 'pptx',
// OpenDocument
'odt', 'ott', 'ods', 'ots', 'odp', 'otp', 'odg', 'otg',
// Text formats
'txt', 'text', 'xml', 'rtf', 'html', 'lwp', 'md',
// Images
'bmp', 'gif', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'pbm', 'pgm', 'ppm', 'ras', 'xbm', 'xpm', 'svg', 'svm', 'wmf', 'webp',
// StarOffice
'sda', 'sdc', 'sdd', 'sdw', 'stc', 'std', 'sti', 'stw', 'sxd', 'sxg', 'sxi', 'sxw',
// Email formats
'eml',
// Archive formats
'zip',
// Other
'dbf', 'fods', 'vsd', 'vor', 'vor3', 'vor4', 'uop', 'pct', 'ps', 'pdf',
];
+17 -1
View File
@@ -4,9 +4,11 @@ import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
interface RightRailContextValue {
buttons: RightRailButtonConfig[];
actions: Record<string, RightRailAction>;
allButtonsDisabled: boolean;
registerButtons: (buttons: RightRailButtonConfig[]) => void;
unregisterButtons: (ids: string[]) => void;
setAction: (id: string, action: RightRailAction) => void;
setAllRightRailButtonsDisabled: (disabled: boolean) => void;
clear: () => void;
}
@@ -15,6 +17,7 @@ const RightRailContext = createContext<RightRailContextValue | undefined>(undefi
export function RightRailProvider({ children }: { children: React.ReactNode }) {
const [buttons, setButtons] = useState<RightRailButtonConfig[]>([]);
const [actions, setActions] = useState<Record<string, RightRailAction>>({});
const [allButtonsDisabled, setAllButtonsDisabled] = useState<boolean>(false);
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
setButtons(prev => {
@@ -43,12 +46,25 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) {
setActions(prev => ({ ...prev, [id]: action }));
}, []);
const setAllRightRailButtonsDisabled = useCallback((disabled: boolean) => {
setAllButtonsDisabled(disabled);
}, []);
const clear = useCallback(() => {
setButtons([]);
setActions({});
}, []);
const value = useMemo<RightRailContextValue>(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]);
const value = useMemo<RightRailContextValue>(() => ({
buttons,
actions,
allButtonsDisabled,
registerButtons,
unregisterButtons,
setAction,
setAllRightRailButtonsDisabled,
clear
}), [buttons, actions, allButtonsDisabled, registerButtons, unregisterButtons, setAction, setAllRightRailButtonsDisabled, clear]);
return (
<RightRailContext.Provider value={value}>
+3 -1
View File
@@ -7,6 +7,7 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
// All sidebar state management
const quickAccessRef = useRef<HTMLDivElement>(null);
const toolPanelRef = useRef<HTMLDivElement>(null);
const rightRailRef = useRef<HTMLDivElement>(null);
const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
@@ -21,7 +22,8 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
const sidebarRefs: SidebarRefs = useMemo(() => ({
quickAccessRef,
toolPanelRef,
}), [quickAccessRef, toolPanelRef]);
rightRailRef,
}), [quickAccessRef, toolPanelRef, rightRailRef]);
const contextValue: SidebarContextValue = useMemo(() => ({
sidebarState,
+65 -70
View File
@@ -3,72 +3,26 @@
* Eliminates prop drilling with a single, simple context
*/
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
import { useToolManagement } from '../hooks/useToolManagement';
import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy';
import { useNavigationActions, useNavigationState } from './NavigationContext';
import { ToolId, isValidToolId } from '../types/toolId';
import { useNavigationUrlSync } from '../hooks/useUrlSync';
import { getDefaultWorkbench } from '../types/workbench';
import { filterToolRegistryByQuery } from '../utils/toolSearch';
import { useToolHistory } from '../hooks/tools/useUserToolActivity';
import {
ToolWorkflowState,
TOOL_PANEL_MODE_STORAGE_KEY,
createInitialState,
toolWorkflowReducer,
ToolPanelMode,
} from './toolWorkflow/toolWorkflowState';
import { usePreferences } from './PreferencesContext';
// State interface
interface ToolWorkflowState {
// UI State
sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
readerMode: boolean;
// File/Preview State
previewFile: File | null;
pageEditorFunctions: PageEditorFunctions | null;
// Search State
searchQuery: string;
}
// Actions
type ToolWorkflowAction =
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' }
| { type: 'SET_READER_MODE'; payload: boolean }
| { type: 'SET_PREVIEW_FILE'; payload: File | null }
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
| { type: 'SET_SEARCH_QUERY'; payload: string }
| { type: 'RESET_UI_STATE' };
// Initial state
const initialState: ToolWorkflowState = {
sidebarsVisible: true,
leftPanelView: 'toolPicker',
readerMode: false,
previewFile: null,
pageEditorFunctions: null,
searchQuery: '',
};
// Reducer
function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState {
switch (action.type) {
case 'SET_SIDEBARS_VISIBLE':
return { ...state, sidebarsVisible: action.payload };
case 'SET_LEFT_PANEL_VIEW':
return { ...state, leftPanelView: action.payload };
case 'SET_READER_MODE':
return { ...state, readerMode: action.payload };
case 'SET_PREVIEW_FILE':
return { ...state, previewFile: action.payload };
case 'SET_PAGE_EDITOR_FUNCTIONS':
return { ...state, pageEditorFunctions: action.payload };
case 'SET_SEARCH_QUERY':
return { ...state, searchQuery: action.payload };
case 'RESET_UI_STATE':
return { ...initialState, searchQuery: state.searchQuery }; // Preserve search
default:
return state;
}
}
// Types and reducer/state moved to './toolWorkflow/state'
// Context value interface
interface ToolWorkflowContextValue extends ToolWorkflowState {
@@ -82,11 +36,12 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
setSidebarsVisible: (visible: boolean) => void;
setLeftPanelView: (view: 'toolPicker' | 'toolContent' | 'hidden') => void;
setReaderMode: (mode: boolean) => void;
setToolPanelMode: (mode: ToolPanelMode) => void;
setPreviewFile: (file: File | null) => void;
setPageEditorFunctions: (functions: PageEditorFunctions | null) => void;
setSearchQuery: (query: string) => void;
// Tool Actions
selectTool: (toolId: ToolId | null) => void;
clearToolSelection: () => void;
@@ -103,6 +58,11 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
// Computed values
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
isPanelVisible: boolean;
// Tool History
favoriteTools: ToolId[];
toggleFavorite: (toolId: ToolId) => void;
isFavorite: (toolId: ToolId) => boolean;
}
const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(undefined);
@@ -113,7 +73,8 @@ interface ToolWorkflowProviderProps {
}
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
const [state, dispatch] = useReducer(toolWorkflowReducer, undefined, createInitialState);
const { preferences } = usePreferences();
// Store reset functions for tools
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
@@ -128,6 +89,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
getSelectedTool,
} = useToolManagement();
// Tool history hook
const {
favoriteTools,
toggleFavorite,
isFavorite,
} = useToolHistory();
// Get selected tool from navigation context
const selectedTool = getSelectedTool(navigationState.selectedTool);
@@ -148,6 +116,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_READER_MODE', payload: mode });
}, [actions]);
const setToolPanelMode = useCallback((mode: ToolPanelMode) => {
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode });
}, []);
const setPreviewFile = useCallback((file: File | null) => {
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
if (file) {
@@ -163,6 +136,28 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, state.toolPanelMode);
}, [state.toolPanelMode]);
// Keep tool panel mode in sync with user preference. This ensures the
// Config setting (Default tool picker mode) immediately affects the app
// and persists across reloads.
useEffect(() => {
if (!preferences) return;
const preferredMode = preferences.defaultToolPanelMode;
if (preferredMode && preferredMode !== state.toolPanelMode) {
dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: preferredMode });
if (typeof window !== 'undefined') {
window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, preferredMode);
}
}
}, [preferences.defaultToolPanelMode]);
// Tool reset methods
const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => {
setToolResetFunctions(prev => ({ ...prev, [toolId]: resetFunction }));
@@ -190,7 +185,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
return;
}
// Handle multiTool selection - enable page editor workbench and hide left panel
// Handle multiTool selection - enable page editor workbench
if (toolId === 'multiTool') {
setReaderMode(false);
setLeftPanelView('hidden');
@@ -239,15 +234,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
[state.sidebarsVisible, state.readerMode, state.leftPanelView]
);
// URL sync for proper tool navigation
useNavigationUrlSync(
navigationState.selectedTool,
handleToolSelect,
handleBackToTools,
toolRegistry as ToolRegistry,
true
);
// Properly memoized context value
const contextValue = useMemo((): ToolWorkflowContextValue => ({
// State
@@ -261,6 +247,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
setToolPanelMode,
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
@@ -280,6 +267,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Computed
filteredTools,
isPanelVisible,
// Tool History
favoriteTools,
toggleFavorite,
isFavorite,
}), [
state,
navigationState.selectedTool,
@@ -289,6 +281,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
setToolPanelMode,
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
@@ -300,6 +293,9 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
handleReaderToggle,
filteredTools,
isPanelVisible,
favoriteTools,
toggleFavorite,
isFavorite,
]);
return (
@@ -313,7 +309,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext);
if (!context) {
console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
+1 -1
View File
@@ -136,7 +136,7 @@ export function createChildStub(
const originalFileId = parentStub.originalFileId || parentStub.id;
// Copy parent metadata but exclude processedFile to prevent stale data
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
const { processedFile: _processedFile, activeJobs: _activeJobs, ...parentMetadata } = parentStub;
return {
// Copy parent metadata (excluding processedFile)
+5 -1
View File
@@ -87,7 +87,11 @@ export function createFileSelectors(
return stateRef.current.files.ids
.map(id => {
const record = stateRef.current.files.byId[id];
return record ? `${id}:${record.size}:${record.lastModified}` : '';
if (!record) return '';
const jobsSignature = (record.activeJobs || [])
.map(job => `${job.jobId}:${job.status}:${Math.round(job.progressPercent)}`)
.join(';');
return `${id}:${record.size}:${record.lastModified}:${jobsSignature}`;
})
.join('|');
},
@@ -0,0 +1,88 @@
import { PageEditorFunctions } from '../../types/pageEditor';
// State & Modes
export type ToolPanelMode = 'sidebar' | 'fullscreen';
export interface ToolWorkflowState {
// UI State
sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent' | 'hidden';
readerMode: boolean;
toolPanelMode: ToolPanelMode;
previewFile: File | null;
pageEditorFunctions: PageEditorFunctions | null;
// Search State
searchQuery: string;
}
// Actions
export type ToolWorkflowAction =
| { type: 'SET_SIDEBARS_VISIBLE'; payload: boolean }
| { type: 'SET_LEFT_PANEL_VIEW'; payload: 'toolPicker' | 'toolContent' | 'hidden' }
| { type: 'SET_READER_MODE'; payload: boolean }
| { type: 'SET_TOOL_PANEL_MODE'; payload: ToolPanelMode }
| { type: 'SET_PREVIEW_FILE'; payload: File | null }
| { type: 'SET_PAGE_EDITOR_FUNCTIONS'; payload: PageEditorFunctions | null }
| { type: 'SET_SEARCH_QUERY'; payload: string }
| { type: 'RESET_UI_STATE' };
// Storage keys
export const TOOL_PANEL_MODE_STORAGE_KEY = 'toolPanelModePreference';
export const getStoredToolPanelMode = (): ToolPanelMode => {
if (typeof window === 'undefined') {
return 'sidebar';
}
const stored = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY);
if (stored === 'fullscreen') {
return 'fullscreen';
}
return 'sidebar';
};
export const baseState: Omit<ToolWorkflowState, 'toolPanelMode'> = {
sidebarsVisible: true,
leftPanelView: 'toolPicker',
readerMode: false,
previewFile: null,
pageEditorFunctions: null,
searchQuery: '',
};
export const createInitialState = (): ToolWorkflowState => ({
...baseState,
toolPanelMode: getStoredToolPanelMode(),
});
export function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState {
switch (action.type) {
case 'SET_SIDEBARS_VISIBLE':
return { ...state, sidebarsVisible: action.payload };
case 'SET_LEFT_PANEL_VIEW':
return { ...state, leftPanelView: action.payload };
case 'SET_READER_MODE':
return { ...state, readerMode: action.payload };
case 'SET_TOOL_PANEL_MODE':
return { ...state, toolPanelMode: action.payload };
case 'SET_PREVIEW_FILE':
return { ...state, previewFile: action.payload };
case 'SET_PAGE_EDITOR_FUNCTIONS':
return { ...state, pageEditorFunctions: action.payload };
case 'SET_SEARCH_QUERY':
return { ...state, searchQuery: action.payload };
case 'RESET_UI_STATE':
return {
...baseState,
toolPanelMode: state.toolPanelMode,
searchQuery: state.searchQuery,
};
default:
return state;
}
}
+51 -11
View File
@@ -4,6 +4,17 @@ import { ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
import { BaseToolProps } from '../types/tool';
import { WorkbenchType } from '../types/workbench';
import { ToolId } from '../types/toolId';
import DrawRoundedIcon from '@mui/icons-material/DrawRounded';
import SecurityRoundedIcon from '@mui/icons-material/SecurityRounded';
import VerifiedUserRoundedIcon from '@mui/icons-material/VerifiedUserRounded';
import RateReviewRoundedIcon from '@mui/icons-material/RateReviewRounded';
import ViewAgendaRoundedIcon from '@mui/icons-material/ViewAgendaRounded';
import FileDownloadRoundedIcon from '@mui/icons-material/FileDownloadRounded';
import DeleteSweepRoundedIcon from '@mui/icons-material/DeleteSweepRounded';
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
export enum SubcategoryId {
SIGNING = 'signing',
@@ -66,17 +77,46 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [
];
export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
[SubcategoryId.SIGNING]: '#FF7892',
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
[SubcategoryId.VERIFICATION]: '#1BB1D4',
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
[SubcategoryId.EXTRACTION]: '#1BB1D4',
[SubcategoryId.REMOVAL]: '#7882FF',
[SubcategoryId.AUTOMATION]: '#69DC95',
[SubcategoryId.GENERAL]: '#69DC95',
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
[SubcategoryId.SIGNING]: 'var(--category-color-signing)', // Green
[SubcategoryId.DOCUMENT_SECURITY]: 'var(--category-color-security)', // Orange
[SubcategoryId.VERIFICATION]: 'var(--category-color-verification)', // Orange
[SubcategoryId.DOCUMENT_REVIEW]: 'var(--category-color-general)', // Blue
[SubcategoryId.PAGE_FORMATTING]: 'var(--category-color-formatting)', // Purple
[SubcategoryId.EXTRACTION]: 'var(--category-color-extraction)', // Cyan
[SubcategoryId.REMOVAL]: 'var(--category-color-removal)', // Red
[SubcategoryId.AUTOMATION]: 'var(--category-color-automation)', // Pink
[SubcategoryId.GENERAL]: 'var(--category-color-general)', // Blue
[SubcategoryId.ADVANCED_FORMATTING]: 'var(--category-color-formatting)', // Purple
[SubcategoryId.DEVELOPER_TOOLS]: 'var(--category-color-developer)', // Gray
};
export const getSubcategoryIcon = (subcategory: SubcategoryId): React.ReactNode => {
switch (subcategory) {
case SubcategoryId.SIGNING:
return React.createElement(DrawRoundedIcon);
case SubcategoryId.DOCUMENT_SECURITY:
return React.createElement(SecurityRoundedIcon);
case SubcategoryId.VERIFICATION:
return React.createElement(VerifiedUserRoundedIcon);
case SubcategoryId.DOCUMENT_REVIEW:
return React.createElement(RateReviewRoundedIcon);
case SubcategoryId.PAGE_FORMATTING:
return React.createElement(ViewAgendaRoundedIcon);
case SubcategoryId.EXTRACTION:
return React.createElement(FileDownloadRoundedIcon);
case SubcategoryId.REMOVAL:
return React.createElement(DeleteSweepRoundedIcon);
case SubcategoryId.AUTOMATION:
return React.createElement(SmartToyRoundedIcon);
case SubcategoryId.GENERAL:
return React.createElement(BuildRoundedIcon);
case SubcategoryId.ADVANCED_FORMATTING:
return React.createElement(TuneRoundedIcon);
case SubcategoryId.DEVELOPER_TOOLS:
return React.createElement(CodeRoundedIcon);
default:
return React.createElement(BuildRoundedIcon);
}
};
export const getCategoryLabel = (t: TFunction, id: ToolCategoryId): string => t(`toolPicker.categories.${id}`, id);
+13 -87
View File
@@ -66,6 +66,10 @@ import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useEx
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
import { removePagesOperationConfig } from "../hooks/tools/removePages/useRemovePagesOperation";
import { removeBlanksOperationConfig } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation";
import { overlayPdfsOperationConfig } from "../hooks/tools/overlayPdfs/useOverlayPdfsOperation";
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
@@ -81,16 +85,14 @@ import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import ReplaceColor from "../tools/ReplaceColor";
import ScannerImageSplit from "../tools/ScannerImageSplit";
import OverlayPdfs from "../tools/OverlayPdfs";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
import SignSettings from "../components/tools/sign/SignSettings";
import AddPageNumbers from "../tools/AddPageNumbers";
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import RemoveAnnotations from "../tools/RemoveAnnotations";
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings";
import ExtractImages from "../tools/ExtractImages";
@@ -105,89 +107,12 @@ import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttach
import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings";
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSettings";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
// Convert tool supported file formats
export const CONVERT_SUPPORTED_FORMATS = [
// Microsoft Office
"doc",
"docx",
"dot",
"dotx",
"csv",
"xls",
"xlsx",
"xlt",
"xltx",
"slk",
"dif",
"ppt",
"pptx",
// OpenDocument
"odt",
"ott",
"ods",
"ots",
"odp",
"otp",
"odg",
"otg",
// Text formats
"txt",
"text",
"xml",
"rtf",
"html",
"lwp",
"md",
// Images
"bmp",
"gif",
"jpeg",
"jpg",
"png",
"tif",
"tiff",
"pbm",
"pgm",
"ppm",
"ras",
"xbm",
"xpm",
"svg",
"svm",
"wmf",
"webp",
// StarOffice
"sda",
"sdc",
"sdd",
"sdw",
"stc",
"std",
"sti",
"stw",
"sxd",
"sxg",
"sxi",
"sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf",
"fods",
"vsd",
"vor",
"vor3",
"vor4",
"uop",
"pct",
"ps",
"pdf",
];
import { CONVERT_SUPPORTED_FORMATS } from "../constants/convertSupportedFornats";
// Hook to get the translated tool registry
export function useFlatToolRegistry(): ToolRegistry {
@@ -704,13 +629,14 @@ export function useFlatToolRegistry(): ToolRegistry {
},
overlayPdfs: {
icon: <LocalIcon icon="layers-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.overlayPdfs.title", "Overlay PDFs"),
component: null,
description: t("home.overlayPdfs.desc", "Overlay one PDF on top of another"),
name: t("home.overlay-pdfs.title", "Overlay PDFs"),
component: OverlayPdfs,
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "overlayPdfs"),
automationSettings: null
operationConfig: overlayPdfsOperationConfig,
synonyms: getSynonyms(t, "overlay-pdfs"),
automationSettings: OverlayPdfsSettings
},
replaceColor: {
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType, type ToolOperationConfig } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { type OverlayPdfsParameters } from './useOverlayPdfsParameters';
const buildFormData = (parameters: OverlayPdfsParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
// Overlay files
for (const overlay of parameters.overlayFiles || []) {
formData.append('overlayFiles', overlay);
}
// Mode and position
formData.append('overlayMode', parameters.overlayMode);
formData.append('overlayPosition', String(parameters.overlayPosition));
// Counts (only relevant for FixedRepeatOverlay, server accepts repeated 'counts' fields)
if (parameters.overlayMode === 'FixedRepeatOverlay') {
for (const count of parameters.counts || []) {
formData.append('counts', String(count));
}
}
return formData;
};
export const overlayPdfsOperationConfig: ToolOperationConfig<OverlayPdfsParameters> = {
toolType: ToolType.singleFile,
buildFormData,
operationType: 'overlayPdfs',
endpoint: '/api/v1/general/overlay-pdfs'
};
export const useOverlayPdfsOperation = () => {
const { t } = useTranslation();
return useToolOperation<OverlayPdfsParameters>({
...overlayPdfsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('overlay-pdfs.error.failed', 'An error occurred while overlaying PDFs.')
),
});
};
@@ -0,0 +1,37 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, type BaseParametersHook } from '../shared/useBaseParameters';
export type OverlayMode = 'SequentialOverlay' | 'InterleavedOverlay' | 'FixedRepeatOverlay';
export interface OverlayPdfsParameters extends BaseParameters {
overlayFiles: File[];
overlayMode: OverlayMode;
overlayPosition: 0 | 1;
counts: number[];
}
export const defaultParameters: OverlayPdfsParameters = {
overlayFiles: [],
overlayMode: 'SequentialOverlay',
overlayPosition: 0,
counts: []
};
export type OverlayPdfsParametersHook = BaseParametersHook<OverlayPdfsParameters>;
export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
return useBaseParameters<OverlayPdfsParameters>({
defaultParameters,
endpointName: 'overlay-pdfs',
validateFn: (params) => {
if (!params.overlayFiles || params.overlayFiles.length === 0) return false;
if (params.overlayMode === 'FixedRepeatOverlay') {
if (!params.counts || params.counts.length !== params.overlayFiles.length) return false;
if (params.counts.some((c) => !Number.isFinite(c) || c <= 0)) return false;
}
return true;
},
});
};
@@ -1,9 +1,19 @@
import { useCallback, useRef } from 'react';
import axios, {type CancelTokenSource} from 'axios'; // Real axios for static methods (CancelToken, isCancel)
import axios, { type CancelTokenSource } from 'axios'; // Real axios for static methods (CancelToken, isCancel)
import apiClient from '../../../services/apiClient'; // Our configured instance
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
import { isEmptyOutput } from '../../../services/errorUtils';
import {
ensureAsyncParam,
waitForJobCompletion,
fetchJobResult,
downloadResultFile,
readJobResponseBlob,
} from '../../../services/jobService';
import type { JobStatus } from '../../../services/jobService';
import type { ProcessingProgress } from './useToolState';
import type { FileId } from '../../../types/file';
import type { FileJobStatus } from '../../../types/fileContext';
export interface ApiCallsConfig<TParams = void> {
endpoint: string | ((params: TParams) => string);
@@ -13,8 +23,233 @@ export interface ApiCallsConfig<TParams = void> {
preserveBackendFilename?: boolean;
}
export interface BatchApiCallsConfig<TParams = void> extends Omit<ApiCallsConfig<TParams>, 'buildFormData'> {
buildFormData: (params: TParams, files: File[]) => FormData;
}
export interface JobUpdate {
jobId: string;
status: FileJobStatus;
progressPercent: number;
message?: string;
queuePosition?: number | null;
error?: string;
}
type JobUpdateCallback = (fileIds: FileId[], update: JobUpdate) => void;
type BuildStatus = (status: JobStatus) => JobUpdate;
interface RunToolJobOptions<TParams> {
params: TParams;
endpoint: string;
formData: FormData;
originalFiles: File[];
filePrefix?: string;
responseHandler?: ResponseHandler;
preserveBackendFilename?: boolean;
onStatus: (status: string) => void;
onJobUpdate?: JobUpdateCallback;
buildStatus: BuildStatus;
isCancelled: () => boolean;
cancelToken?: CancelTokenSource | null;
}
const DEFAULT_PROGRESS_FALLBACK = 10;
function getFileIds(files: File[]): FileId[] {
return files
.map(file => (file as any)?.fileId)
.filter((id): id is FileId => typeof id === 'string' && id.length > 0);
}
export const useToolApiCalls = <TParams = void>() => {
const cancelTokenRef = useRef<CancelTokenSource | null>(null);
const isCancelledRef = useRef(false);
const jobStatusToUpdate = useCallback<BuildStatus>((status) => {
const hasError = Boolean(status.error);
const isComplete = status.complete && !hasError;
let derivedStatus: FileJobStatus = 'processing';
if (hasError) {
derivedStatus = 'failed';
} else if (status.inQueue && !status.complete) {
derivedStatus = 'queued';
} else if (isComplete) {
derivedStatus = 'completed';
}
const queueMessage = (() => {
if (!status.inQueue) return undefined;
if (typeof status.queuePosition === 'number' && status.queuePosition >= 0) {
return `Queued (#${status.queuePosition + 1})`;
}
return 'Queued';
})();
const message =
status.error ??
status.progressMessage ??
(isComplete ? 'Completed' : queueMessage);
const progress = typeof status.progressPercent === 'number'
? status.progressPercent
: derivedStatus === 'completed'
? 100
: derivedStatus === 'queued'
? 0
: DEFAULT_PROGRESS_FALLBACK;
return {
jobId: status.jobId,
status: derivedStatus,
progressPercent: Math.max(0, Math.min(progress, 100)),
message,
queuePosition: typeof status.queuePosition === 'number' ? status.queuePosition : null,
error: status.error ?? undefined,
};
}, []);
const runToolJob = useCallback(async <T>(options: RunToolJobOptions<T>): Promise<File[]> => {
const {
endpoint,
formData,
originalFiles,
filePrefix,
responseHandler,
preserveBackendFilename,
onStatus,
onJobUpdate,
buildStatus,
isCancelled,
cancelToken,
} = options;
const asyncEndpoint = ensureAsyncParam(endpoint);
const token = cancelToken?.token;
const response = await apiClient.post(asyncEndpoint, formData, {
responseType: 'blob',
cancelToken: token,
});
const headers = response.headers ?? {};
const contentType = (headers['content-type'] || '') as string;
if (contentType.includes('application/json')) {
const payload = await readJobResponseBlob(response.data);
if (payload && typeof payload === 'object' && payload.async && payload.jobId) {
const fileIds = getFileIds(originalFiles);
const initialUpdate: JobUpdate = {
jobId: payload.jobId,
status: 'queued',
progressPercent: 0,
message: 'Job submitted',
queuePosition: null,
};
onJobUpdate?.(fileIds, initialUpdate);
onStatus(initialUpdate.message ?? 'Job submitted');
const finalStatus = await waitForJobCompletion(payload.jobId, {
cancelToken: token,
isCancelled,
onUpdate: (status) => {
const update = buildStatus(status);
onJobUpdate?.(fileIds, update);
if (update.message) {
onStatus(update.message);
}
},
});
const completionUpdate = buildStatus(finalStatus);
if (completionUpdate.status === 'failed') {
onJobUpdate?.(fileIds, completionUpdate);
if (completionUpdate.message) {
onStatus(completionUpdate.message);
}
throw new Error(completionUpdate.error || 'Job failed');
}
const downloadUpdate: JobUpdate = {
jobId: completionUpdate.jobId,
status: 'processing',
progressPercent: Math.max(
96,
completionUpdate.progressPercent
? Math.min(completionUpdate.progressPercent, 98)
: 96,
),
message: 'Downloading results...',
queuePosition: null,
};
onJobUpdate?.(fileIds, downloadUpdate);
onStatus(downloadUpdate.message);
const jobResult = await fetchJobResult(payload.jobId, token);
const prepareUpdate: JobUpdate = {
jobId: completionUpdate.jobId,
status: 'processing',
progressPercent: Math.max(downloadUpdate.progressPercent, 98),
message: 'Preparing files...',
queuePosition: null,
};
let processedFiles: File[];
if (jobResult.type === 'blob') {
processedFiles = await processResponse(
jobResult.blob,
originalFiles,
filePrefix,
responseHandler,
preserveBackendFilename ? jobResult.headers : undefined,
);
} else if (jobResult.type === 'multipleFiles') {
processedFiles = await Promise.all(
jobResult.files.map(meta => downloadResultFile(meta, token))
);
} else {
throw new Error('Unsupported async job result format');
}
onJobUpdate?.(fileIds, prepareUpdate);
if (prepareUpdate.message) {
onStatus(prepareUpdate.message);
}
const finalNormalizedUpdate = {
...completionUpdate,
progressPercent: 100,
message: completionUpdate.message ?? 'Completed',
};
onJobUpdate?.(fileIds, finalNormalizedUpdate);
if (finalNormalizedUpdate.message) {
onStatus(finalNormalizedUpdate.message);
}
return processedFiles;
}
if (payload && typeof payload === 'object' && payload.error) {
throw new Error(payload.error);
}
throw new Error('Async job response missing jobId');
}
// Fallback: backend returned immediate blob (synchronous)
return processResponse(
response.data,
originalFiles,
filePrefix,
responseHandler,
preserveBackendFilename ? headers : undefined,
);
}, []);
const processFiles = useCallback(async (
params: TParams,
@@ -23,70 +258,62 @@ export const useToolApiCalls = <TParams = void>() => {
onProgress: (progress: ProcessingProgress) => void,
onStatus: (status: string) => void,
markFileError?: (fileId: string) => void,
onJobUpdate?: JobUpdateCallback,
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
const processedFiles: File[] = [];
const successSourceIds: string[] = [];
const failedFiles: string[] = [];
const total = validFiles.length;
// Create cancel token for this operation
isCancelledRef.current = false;
cancelTokenRef.current = axios.CancelToken.source();
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
try {
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
onProgress({ current: i + 1, total, currentFileName: file.name });
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId });
onProgress({ current: i + 1, total, currentFileName: file.name });
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
try {
const formData = config.buildFormData(params, file);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
console.debug('[processFiles] POST', { endpoint, name: file.name });
const response = await apiClient.post(endpoint, formData, {
responseType: 'blob',
cancelToken: cancelTokenRef.current?.token,
});
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
// Forward to shared response processor (uses tool-specific responseHandler if provided)
const responseFiles = await processResponse(
response.data,
[file],
config.filePrefix,
config.responseHandler,
config.preserveBackendFilename ? response.headers : undefined
);
// Guard: some endpoints may return an empty/0-byte file with 200
const empty = isEmptyOutput(responseFiles);
if (empty) {
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
failedFiles.push(file.name);
try {
(markFileError as any)?.((file as any).fileId);
} catch (e) {
console.debug('markFileError', e);
}
continue;
}
processedFiles.push(...responseFiles);
// record source id as successful
successSourceIds.push((file as any).fileId);
console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length });
} catch (error) {
if (axios.isCancel(error)) {
throw new Error('Operation was cancelled');
}
console.error('[processFiles] Failed', { name: file.name, error });
failedFiles.push(file.name);
// mark errored file so UI can highlight
try {
(markFileError as any)?.((file as any).fileId);
} catch (e) {
console.debug('markFileError', e);
const formData = config.buildFormData(params, file);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const responseFiles = await runToolJob({
params,
endpoint,
formData,
originalFiles: [file],
filePrefix: config.filePrefix,
responseHandler: config.responseHandler,
preserveBackendFilename: config.preserveBackendFilename,
onStatus,
onJobUpdate,
buildStatus: jobStatusToUpdate,
isCancelled: () => isCancelledRef.current,
cancelToken: cancelTokenRef.current,
});
const empty = isEmptyOutput(responseFiles);
if (empty) {
failedFiles.push(file.name);
markFileError?.((file as any).fileId);
continue;
}
processedFiles.push(...responseFiles);
successSourceIds.push((file as any).fileId);
} catch (error) {
if (axios.isCancel(error) || (error as Error)?.message === 'Operation was cancelled') {
throw new Error('Operation was cancelled');
}
failedFiles.push(file.name);
markFileError?.((file as any).fileId);
console.error('[processFiles] Job failed', { name: file.name, error });
}
}
} finally {
cancelTokenRef.current = null;
}
if (failedFiles.length > 0 && processedFiles.length === 0) {
@@ -99,11 +326,59 @@ export const useToolApiCalls = <TParams = void>() => {
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
}
console.debug('[processFiles] Completed batch', { total, successes: successSourceIds.length, outputs: processedFiles.length, failed: failedFiles.length });
return { outputFiles: processedFiles, successSourceIds };
}, []);
}, [jobStatusToUpdate, runToolJob]);
const processBatchJob = useCallback(async (
params: TParams,
files: File[],
config: BatchApiCallsConfig<TParams>,
onProgress: (progress: ProcessingProgress) => void,
onStatus: (status: string) => void,
onJobUpdate?: JobUpdateCallback,
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
isCancelledRef.current = false;
cancelTokenRef.current = axios.CancelToken.source();
try {
onStatus('Processing files...');
onProgress({ current: 0, total: files.length, currentFileName: files[0]?.name });
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const formData = config.buildFormData(params, files);
const responseFiles = await runToolJob({
params,
endpoint,
formData,
originalFiles: files,
filePrefix: config.filePrefix,
responseHandler: config.responseHandler,
preserveBackendFilename: config.preserveBackendFilename,
onStatus,
onJobUpdate,
buildStatus: jobStatusToUpdate,
isCancelled: () => isCancelledRef.current,
cancelToken: cancelTokenRef.current,
});
const empty = isEmptyOutput(responseFiles);
if (empty) {
throw new Error('No files produced by operation');
}
onProgress({ current: files.length, total: files.length, currentFileName: files[files.length - 1]?.name });
onStatus(`Successfully processed ${responseFiles.length} file${responseFiles.length === 1 ? '' : 's'}`);
const successIds = getFileIds(files).map(id => id as unknown as string);
return { outputFiles: responseFiles, successSourceIds: successIds };
} finally {
cancelTokenRef.current = null;
}
}, [jobStatusToUpdate, runToolJob]);
const cancelOperation = useCallback(() => {
isCancelledRef.current = true;
if (cancelTokenRef.current) {
cancelTokenRef.current.cancel('Operation cancelled by user');
cancelTokenRef.current = null;
@@ -112,6 +387,7 @@ export const useToolApiCalls = <TParams = void>() => {
return {
processFiles,
processBatchJob,
cancelOperation,
};
};
@@ -1,12 +1,11 @@
import { useCallback, useRef, useEffect } from 'react';
import apiClient from '../../../services/apiClient';
import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext';
import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolApiCalls, type ApiCallsConfig, type BatchApiCallsConfig, type JobUpdate } from './useToolApiCalls';
import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext';
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, FileJobProgress } from '../../../types/fileContext';
import { FILE_EVENTS } from '../../../services/errorUtils';
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
@@ -145,13 +144,13 @@ export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
const { consumeFiles, undoConsumeFiles, selectors } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
const { actions: fileActions } = useFileContext();
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
const { processFiles, processBatchJob, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls } = useToolResources();
// Track last operation for undo functionality
const lastOperationRef = useRef<{
@@ -160,6 +159,45 @@ export const useToolOperation = <TParams>(
outputFileIds: FileId[];
} | null>(null);
const handleJobUpdate = useCallback((fileIds: FileId[], update: JobUpdate) => {
if (!fileIds || fileIds.length === 0) {
return;
}
fileIds.forEach(fileId => {
const record = selectors.getStirlingFileStub(fileId);
if (!record) {
return;
}
const existing = record.activeJobs ?? [];
const entry: FileJobProgress = {
jobId: update.jobId,
status: update.status,
progressPercent: Math.max(0, Math.min(update.progressPercent, 100)),
message: update.message,
queuePosition: update.queuePosition ?? null,
error: update.error,
updatedAt: Date.now(),
};
let nextJobs: FileJobProgress[];
if (update.status === 'completed') {
nextJobs = existing.filter(job => job.jobId !== update.jobId);
} else {
const idx = existing.findIndex(job => job.jobId === update.jobId);
if (idx >= 0) {
nextJobs = [...existing];
nextJobs[idx] = entry;
} else {
nextJobs = [...existing, entry];
}
}
fileActions.updateStirlingFileStub(fileId, { activeJobs: nextJobs });
});
}, [selectors, fileActions]);
const executeOperation = useCallback(async (
params: TParams,
selectedFiles: StirlingFile[]
@@ -230,7 +268,8 @@ export const useToolOperation = <TParams>(
apiCallsConfig,
actions.setProgress,
actions.setStatus,
fileActions.markFileError as any
fileActions.markFileError as any,
handleJobUpdate
);
processedFiles = result.outputFiles;
successSourceIds = result.successSourceIds as any;
@@ -238,35 +277,25 @@ export const useToolOperation = <TParams>(
break;
}
case ToolType.multiFile: {
// Multi-file processing - single API call with all files
actions.setStatus('Processing files...');
const formData = config.buildFormData(params, filesForAPI);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const batchConfig: BatchApiCallsConfig<TParams> = {
endpoint: config.endpoint,
buildFormData: config.buildFormData,
filePrefix: config.filePrefix,
responseHandler: config.responseHandler,
preserveBackendFilename: config.preserveBackendFilename,
};
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
const result = await processBatchJob(
params,
filesForAPI,
batchConfig,
actions.setProgress,
actions.setStatus,
handleJobUpdate
);
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, filesForAPI);
} else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - add prefix to first original filename
const filename = `${config.filePrefix}${filesForAPI[0]?.name || 'document.pdf'}`;
const singleFile = new File([response.data], filename, { type: 'application/pdf' });
processedFiles = [singleFile];
} else {
// Default: assume ZIP response for multi-file endpoints
// Note: extractZipFiles will check preferences.autoUnzip setting
processedFiles = await extractZipFiles(response.data);
if (processedFiles.length === 0) {
// Try the generic extraction as fallback
processedFiles = await extractAllZipFiles(response.data);
}
}
// Assume all inputs succeeded together unless server provided an error earlier
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
processedFiles = result.outputFiles;
successSourceIds = result.successSourceIds as any;
break;
}
@@ -446,7 +475,7 @@ export const useToolOperation = <TParams>(
actions.setLoading(false);
actions.setProgress(null);
}
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
}, [t, config, actions, consumeFiles, processFiles, processBatchJob, generateThumbnails, createDownloadInfo, cleanupBlobUrls, handleJobUpdate]);
const cancelOperation = useCallback(() => {
cancelApiCalls();
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { ToolId } from '../../types/toolId';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
export function useFavoriteToolItems(
favoriteTools: ToolId[],
toolRegistry: Partial<Record<ToolId, ToolRegistryEntry>>
): Array<{ id: ToolId; tool: ToolRegistryEntry }> {
return useMemo(() => {
return favoriteTools
.map((toolId) => {
const tool = toolRegistry[toolId as ToolId];
return tool ? { id: toolId as ToolId, tool } : null;
})
.filter((x): x is { id: ToolId; tool: ToolRegistryEntry } => x !== null)
.filter(({ id, tool }) => Boolean(tool.component) || Boolean(tool.link) || id === 'read' || id === 'multiTool');
}, [favoriteTools, toolRegistry]);
}
@@ -0,0 +1,30 @@
import { useState, useEffect, Dispatch, SetStateAction } from 'react';
export function useLocalStorageState<T>(key: string, defaultValue: T): [T, Dispatch<SetStateAction<T>>] {
const [state, setState] = useState<T>(() => {
if (typeof window === 'undefined') {
return defaultValue;
}
const stored = window.localStorage.getItem(key);
if (stored === null) {
return defaultValue;
}
try {
return JSON.parse(stored) as T;
} catch {
return defaultValue;
}
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
@@ -0,0 +1,119 @@
import { useLayoutEffect, useState, RefObject, useRef } from 'react';
export interface ToolPanelGeometry {
left: number;
top: number;
width: number;
height: number;
}
interface UseToolPanelGeometryOptions {
enabled: boolean;
toolPanelRef: RefObject<HTMLDivElement | null>;
quickAccessRef: RefObject<HTMLDivElement | null>;
rightRailRef?: RefObject<HTMLDivElement | null>;
}
export function useToolPanelGeometry({
enabled,
toolPanelRef,
quickAccessRef,
rightRailRef,
}: UseToolPanelGeometryOptions) {
const [geometry, setGeometry] = useState<ToolPanelGeometry | null>(null);
const scheduleUpdateRef = useRef<() => void>(() => {});
useLayoutEffect(() => {
if (!enabled) {
setGeometry(null);
return;
}
const panelEl = toolPanelRef.current;
if (!panelEl) {
setGeometry(null);
return;
}
const rightRailEl = () => (rightRailRef?.current ?? null);
let rafId: number | null = null;
const computeAndSetGeometry = () => {
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
};
const scheduleUpdate = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
computeAndSetGeometry();
rafId = null;
});
};
scheduleUpdateRef.current = scheduleUpdate;
// Initial geometry calculation (no debounce)
computeAndSetGeometry();
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => scheduleUpdate());
resizeObserver.observe(panelEl);
if (quickAccessRef.current) {
resizeObserver.observe(quickAccessRef.current);
}
const rail = rightRailEl();
if (rail) {
resizeObserver.observe(rail);
}
// Observe root element to react to viewport-driven layout changes
if (document.documentElement) {
resizeObserver.observe(document.documentElement);
}
} else {
// Fallback for environments without ResizeObserver
const handleResize = () => scheduleUpdate();
window.addEventListener('resize', handleResize);
// Ensure cleanup of the fallback listener
resizeObserver = {
disconnect: () => window.removeEventListener('resize', handleResize),
} as unknown as ResizeObserver;
}
return () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
scheduleUpdateRef.current = () => {};
resizeObserver?.disconnect();
};
}, [enabled, quickAccessRef, toolPanelRef, rightRailRef]);
// Secondary effect: (re)attach observers when refs' .current become available later
useLayoutEffect(() => {
if (!enabled) return;
if (typeof ResizeObserver === 'undefined') return;
const qa = quickAccessRef.current;
const rail = rightRailRef?.current ?? null;
if (!qa && !rail) return;
const ro = new ResizeObserver(() => scheduleUpdateRef.current());
if (qa) ro.observe(qa);
if (rail) ro.observe(rail);
return () => ro.disconnect();
}, [enabled, quickAccessRef.current, rightRailRef?.current]);
return geometry;
}
@@ -0,0 +1,70 @@
import { useState, useEffect, useCallback } from 'react';
import { ToolId } from '../../types/toolId';
const RECENT_TOOLS_KEY = 'stirlingpdf.recentTools';
const FAVORITE_TOOLS_KEY = 'stirlingpdf.favoriteTools';
export function useToolHistory() {
const [recentTools, setRecentTools] = useState<ToolId[]>([]);
const [favoriteTools, setFavoriteTools] = useState<ToolId[]>([]);
// Load from localStorage on mount
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const recentStr = window.localStorage.getItem(RECENT_TOOLS_KEY);
const favoritesStr = window.localStorage.getItem(FAVORITE_TOOLS_KEY);
if (recentStr) {
try {
const recent = JSON.parse(recentStr) as ToolId[];
setRecentTools(recent);
} catch {
// Ignore parse errors
}
}
if (favoritesStr) {
try {
const favorites = JSON.parse(favoritesStr) as ToolId[];
setFavoriteTools(favorites);
} catch {
// Ignore parse errors
}
}
}, []);
// Toggle favorite status
const toggleFavorite = useCallback((toolId: ToolId) => {
if (typeof window === 'undefined') {
return;
}
setFavoriteTools((prev) => {
const isFavorite = prev.includes(toolId);
const updated = isFavorite
? prev.filter((id) => id !== toolId)
: [...prev, toolId];
window.localStorage.setItem(FAVORITE_TOOLS_KEY, JSON.stringify(updated));
return updated;
});
}, []);
// Check if a tool is favorited
const isFavorite = useCallback(
(toolId: ToolId): boolean => {
return favoriteTools.includes(toolId);
},
[favoriteTools]
);
return {
recentTools,
favoriteTools,
toggleFavorite,
isFavorite,
};
}

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