Compare commits
4
Commits
docs
...
progressBar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eafe68e58 | ||
|
|
b54beaa66b | ||
|
|
e542f4bc61 | ||
|
|
a339f71116 |
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## 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();
|
||||
}
|
||||
}
|
||||
|
||||
+120
-24
@@ -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
|
||||
*
|
||||
|
||||
+34
-4
@@ -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
|
||||
|
||||
+4
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-2
@@ -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());
|
||||
|
||||
+23
-6
@@ -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";
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+14
-1
@@ -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);
|
||||
}
|
||||
|
||||
+51
-8
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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(
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
|
||||
+24
-2
@@ -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;
|
||||
|
||||
+44
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-6
@@ -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);
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+56
-8
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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
|
||||
|
||||
+3
-1
@@ -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. */
|
||||
|
||||
+7
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE_ELEMENTS = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(', ');
|
||||
|
||||
export function useFocusTrap(containerRef: RefObject<HTMLElement | null>, enabled: boolean = true) {
|
||||
useEffect(() => {
|
||||
if (!enabled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const getFocusableElements = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS));
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
// Check if focus is within the container
|
||||
if (!container.contains(activeElement)) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift + Tab (backwards)
|
||||
if (event.shiftKey) {
|
||||
if (activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
}
|
||||
// Tab (forwards)
|
||||
else {
|
||||
if (activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Focus first element on mount
|
||||
const focusableElements = getFocusableElements();
|
||||
if (focusableElements.length > 0) {
|
||||
// Small delay to ensure the element is fully rendered
|
||||
setTimeout(() => {
|
||||
focusableElements[0]?.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [containerRef, enabled]);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
|
||||
availableToolRegistry[toolKey] = {
|
||||
availableToolRegistry[toolKey as ToolId] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user