mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
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);
|
||||
|
||||
+247
-239
@@ -8,15 +8,14 @@ Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This
|
||||
|
||||
Stirling-PDF is built using:
|
||||
|
||||
- Spring Boot (Backend API)
|
||||
- React + TypeScript + Vite (Frontend V2)
|
||||
- Mantine UI + TailwindCSS (UI Framework)
|
||||
- PDFBox (PDF manipulation)
|
||||
- LibreOffice (Document conversion)
|
||||
- qpdf (PDF processing)
|
||||
- PDF.js (Client-side PDF rendering)
|
||||
- Embedded-PDF (PDF viewer component)
|
||||
- Spring Boot + Thymeleaf
|
||||
- PDFBox
|
||||
- LibreOffice
|
||||
- qpdf
|
||||
- HTML, CSS, JavaScript
|
||||
- Docker
|
||||
- PDF.js
|
||||
- PDF-LIB.js
|
||||
- Lombok
|
||||
|
||||
## 3. Development Environment Setup
|
||||
@@ -25,9 +24,8 @@ Stirling-PDF is built using:
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Java JDK 17 or later
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- Node.js 18+ and npm (for frontend development)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
@@ -40,8 +38,8 @@ Stirling-PDF is built using:
|
||||
|
||||
2. Install Docker and JDK17 if not already installed.
|
||||
|
||||
3. Install a recommended IDE:
|
||||
- **VSCode** (recommended for frontend)
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
1. Open VS Code.
|
||||
2. When prompted, install the recommended extensions.
|
||||
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
|
||||
@@ -51,15 +49,13 @@ Stirling-PDF is built using:
|
||||
```
|
||||
|
||||
4. Install the required extensions from the list.
|
||||
- **IntelliJ IDEA** (recommended for backend)
|
||||
- **Eclipse** (alternative for backend)
|
||||
|
||||
4. Lombok Setup
|
||||
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
|
||||
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
|
||||
|
||||
5. Add environment variable
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
|
||||
|
||||
## 4. Project Structure
|
||||
|
||||
@@ -72,21 +68,10 @@ Stirling-PDF/
|
||||
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
|
||||
├── docs/ # Documentation files
|
||||
├── exampleYmlFiles/ # Example YAML configuration files
|
||||
├── frontend/ # React frontend application (V2)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── tools/ # PDF tool implementations
|
||||
│ │ ├── contexts/ # React contexts (FileContext, etc.)
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── services/ # API and processing services
|
||||
│ │ └── i18n.ts # Internationalization config
|
||||
│ ├── public/
|
||||
│ │ └── locales/ # Translation JSON files
|
||||
│ └── package.json
|
||||
├── images/ # Image assets
|
||||
├── pipeline/ # Pipeline-related files (generated at runtime)
|
||||
├── scripts/ # Utility scripts
|
||||
├── src/ # Backend source code
|
||||
├── src/ # Source code
|
||||
│ ├── main/
|
||||
│ │ ├── java/
|
||||
│ │ │ └── stirling/
|
||||
@@ -94,14 +79,16 @@ Stirling-PDF/
|
||||
│ │ │ └── SPDF/
|
||||
│ │ │ ├── config/
|
||||
│ │ │ ├── controller/
|
||||
│ │ │ │ ├── api/ # REST API endpoints
|
||||
│ │ │ │ └── web/ # Web controllers
|
||||
│ │ │ ├── model/
|
||||
│ │ │ ├── repository/
|
||||
│ │ │ ├── service/
|
||||
│ │ │ └── utils/
|
||||
│ │ └── resources/
|
||||
│ │ └── static/ # Legacy static assets
|
||||
│ │ ├── static/
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── pdfjs/
|
||||
│ │ └── templates/
|
||||
│ └── test/
|
||||
│ └── java/
|
||||
│ └── stirling/
|
||||
@@ -154,7 +141,7 @@ services:
|
||||
- ./stirling/latest/config:/configs:rw
|
||||
- ./stirling/latest/logs:/logs:rw
|
||||
environment:
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
@@ -183,7 +170,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
1. Set the security environment variable:
|
||||
|
||||
```bash
|
||||
export DOCKER_ENABLE_SECURITY=true # or false to disable login and security features for builds
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
|
||||
```
|
||||
|
||||
2. Build the project with Gradle:
|
||||
@@ -209,7 +196,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
For the fat version (with login and security features enabled):
|
||||
|
||||
```bash
|
||||
export DOCKER_ENABLE_SECURITY=true
|
||||
export DISABLE_ADDITIONAL_FEATURES=false
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
|
||||
```
|
||||
|
||||
@@ -237,50 +224,38 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
|
||||
|
||||
### Full Testing with Docker
|
||||
|
||||
1. Build and run the Docker container per the above instructions
|
||||
1. Build and run the Docker container per the above instructions:
|
||||
|
||||
2. Access the application at `http://localhost:8080` and manually test all features developed.
|
||||
|
||||
### Local Testing (Frontend and Backend)
|
||||
### Local Testing (Java and UI Components)
|
||||
|
||||
For quick iterations and development, you can run the frontend and backend separately:
|
||||
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
|
||||
|
||||
#### Backend Development
|
||||
- Java backend logic
|
||||
- RESTful API endpoints
|
||||
- JavaScript functionality
|
||||
- User interface components and styling
|
||||
- Thymeleaf templates
|
||||
|
||||
1. Run the backend:
|
||||
To run Stirling-PDF locally:
|
||||
|
||||
1. Compile and run the project using built-in IDE methods or by running:
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
2. The backend API will be available at `http://localhost:8080`
|
||||
2. Access the application at `http://localhost:8080` in your web browser.
|
||||
|
||||
3. API documentation is available at `http://localhost:8080/swagger-ui/index.html`
|
||||
3. Manually test the features you're working on through the UI.
|
||||
|
||||
#### Frontend Development
|
||||
|
||||
1. Install dependencies (first time only):
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. The frontend will be available at `http://localhost:5173`
|
||||
|
||||
4. Vite automatically proxies API calls from `/api/*` to the backend at `localhost:8080`
|
||||
4. For API changes, use tools like Postman or curl to test endpoints directly.
|
||||
|
||||
Important notes:
|
||||
|
||||
- Frontend requires the backend to be running for full functionality
|
||||
- Hot module replacement (HMR) enables instant updates during development
|
||||
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
|
||||
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
|
||||
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
|
||||
|
||||
## 7. Contributing
|
||||
@@ -332,170 +307,112 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
|
||||
|
||||
Refer to the main README for a full list of customization options.
|
||||
|
||||
## 10. Frontend Development (V2)
|
||||
## 10. Language Translations
|
||||
|
||||
### Architecture Overview
|
||||
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
|
||||
|
||||
The V2 frontend is designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
### Key Components
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/src/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```bash
|
||||
/scripts/replace_translation_line.sh
|
||||
```
|
||||
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
This script helps you make consistent replacements across language files.
|
||||
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
When contributing translations:
|
||||
|
||||
1. Use the helper script for multi-file changes.
|
||||
2. Ensure all language files are updated consistently.
|
||||
3. The PR checks will verify consistency in language file updates.
|
||||
|
||||
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
|
||||
|
||||
## Code examples
|
||||
|
||||
### Overview of Thymeleaf
|
||||
|
||||
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
|
||||
|
||||
### Thymeleaf overview
|
||||
|
||||
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `app/core/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
|
||||
|
||||
Some examples of this are:
|
||||
|
||||
```html
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
```
|
||||
or
|
||||
```html
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
```
|
||||
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
|
||||
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
|
||||
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
They can be more complex, such as:
|
||||
|
||||
```html
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
|
||||
|
||||
### Adding a New Tool
|
||||
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
|
||||
|
||||
See [ADDING_TOOLS.md](../ADDING_TOOLS.md) for a complete guide to creating new PDF tools.
|
||||
|
||||
### Internationalization
|
||||
|
||||
Translations are stored in JSON files at `frontend/public/locales/{language-code}/translation.json`.
|
||||
|
||||
To use translations in React components:
|
||||
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('myTool.title')}</h1>
|
||||
<p>{t('myTool.description')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```java
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("exampleData", exampleData);
|
||||
return "new-feature";
|
||||
}
|
||||
```
|
||||
|
||||
See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new languages.
|
||||
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
|
||||
|
||||
## 11. Backend Development
|
||||
```html
|
||||
<tbody>
|
||||
<!-- Use th:each to iterate over the list -->
|
||||
<tr th:each="person : ${exampleData}">
|
||||
<td th:text="${person.id}"></td>
|
||||
<td th:text="${person.name}"></td>
|
||||
<td th:text="${person.age}"></td>
|
||||
<td th:text="${person.email}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
```
|
||||
|
||||
### Adding a New API Endpoint
|
||||
This would generate n entries of tr for each person in exampleData
|
||||
|
||||
### Adding a New Feature to the Backend (API)
|
||||
|
||||
1. **Create a New Controller:**
|
||||
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/api` directory.
|
||||
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/api` directory.
|
||||
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
|
||||
- Ensure to add API documentation annotations like `@Tag` and `@Operation`.
|
||||
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
|
||||
|
||||
```java
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/pdf")
|
||||
@RequestMapping("/api/v1/new-feature")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
public class NewFeatureController {
|
||||
|
||||
@PostMapping("/new-feature")
|
||||
@Operation(summary = "New Feature", description = "This is a new feature endpoint. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> newFeature(
|
||||
@RequestPart("fileInput") MultipartFile file,
|
||||
@RequestParam("param1") String param1) {
|
||||
|
||||
// Process PDF
|
||||
byte[] result = processFile(file, param1);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=output.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(result);
|
||||
@GetMapping
|
||||
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
|
||||
public String newFeature() {
|
||||
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Define the Service Layer:** (Optional but recommended)
|
||||
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
|
||||
2. **Define the Service Layer:** (Not required but often useful)
|
||||
- Create a new service class in the `app/core/src/main/java/stirling/software/SPDF/service` directory.
|
||||
- Implement the business logic for the new feature.
|
||||
|
||||
```java
|
||||
@@ -506,76 +423,167 @@ See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new
|
||||
@Service
|
||||
public class NewFeatureService {
|
||||
|
||||
public byte[] processFile(MultipartFile file, String param1) {
|
||||
public String getNewFeatureData() {
|
||||
// Implement business logic here
|
||||
return processedBytes;
|
||||
return "New Feature Data";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Integrate the Service with the Controller:**
|
||||
2b. **Integrate the Service with the Controller:**
|
||||
|
||||
- Autowire the service class in the controller and use it to handle the API request.
|
||||
|
||||
```java
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import stirling.software.SPDF.service.NewFeatureService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/new-feature")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
public class NewFeatureController {
|
||||
|
||||
@Autowired
|
||||
private NewFeatureService newFeatureService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
|
||||
public String newFeature() {
|
||||
return newFeatureService.getNewFeatureData();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Feature to the Frontend (UI)
|
||||
|
||||
1. **Create a New Thymeleaf Template:**
|
||||
- Create a new HTML file in the `app/core/src/main/resources/templates` directory.
|
||||
- Use Thymeleaf attributes to dynamically generate content.
|
||||
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
|
||||
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
|
||||
</div>
|
||||
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
|
||||
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
|
||||
<input type="hidden" id="customMode" name="customMode" value="">
|
||||
<div class="mb-3">
|
||||
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
|
||||
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
2. **Create a New Controller for the UI:**
|
||||
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/ui` directory.
|
||||
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/pdf")
|
||||
public class NewFeatureController {
|
||||
package stirling.software.SPDF.controller.ui;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import stirling.software.SPDF.service.NewFeatureService;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/new-feature")
|
||||
public class NewFeatureUIController {
|
||||
|
||||
@Autowired
|
||||
private NewFeatureService newFeatureService;
|
||||
|
||||
@PostMapping("/new-feature")
|
||||
public ResponseEntity<byte[]> newFeature(
|
||||
@RequestPart("fileInput") MultipartFile file,
|
||||
@RequestParam("param1") String param1) {
|
||||
|
||||
byte[] result = newFeatureService.processFile(file, param1);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=output.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(result);
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
|
||||
return "new-feature";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-File Endpoints
|
||||
3. **Update the Navigation Bar:**
|
||||
- Add a link to the new feature page in the navigation bar.
|
||||
- Update the `app/core/src/main/resources/templates/fragments/navbar.html` file.
|
||||
|
||||
For tools that process multiple files in one request:
|
||||
```html
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
|
||||
</li>
|
||||
```
|
||||
|
||||
```java
|
||||
@PostMapping("/merge")
|
||||
public ResponseEntity<byte[]> mergePdfs(
|
||||
@RequestPart("fileInput") MultipartFile[] files) {
|
||||
## Adding New Translations to Existing Language Files in Stirling-PDF
|
||||
|
||||
// Process all files together
|
||||
byte[] merged = mergeService.mergeFiles(files);
|
||||
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=merged.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(merged);
|
||||
}
|
||||
### 1. Locate Existing Language Files
|
||||
|
||||
Find the existing `messages.properties` files in the `app/core/src/main/resources` directory. You'll see files like:
|
||||
|
||||
- `messages.properties` (default, usually English)
|
||||
- `messages_en_GB.properties`
|
||||
- `messages_fr_FR.properties`
|
||||
- `messages_de_DE.properties`
|
||||
- etc.
|
||||
|
||||
### 2. Add New Translation Entries
|
||||
|
||||
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
|
||||
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
|
||||
you might add:
|
||||
|
||||
```properties
|
||||
pdfSplitter.title=PDF Splitter
|
||||
pdfSplitter.description=Split your PDF into multiple documents
|
||||
pdfSplitter.button.split=Split PDF
|
||||
pdfSplitter.input.pages=Enter page numbers to split
|
||||
```
|
||||
|
||||
## 12. Best Practices
|
||||
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
|
||||
|
||||
### Frontend
|
||||
- Always use FileContext for file operations
|
||||
- Implement proper cleanup for PDF.js documents and blob URLs
|
||||
- Use the `useToolOperation` hook for consistent tool behavior
|
||||
- Follow TypeScript strict mode guidelines
|
||||
- Test with large files (100MB+) to ensure memory efficiency
|
||||
### 3. Use Translations in Thymeleaf Templates
|
||||
|
||||
### Backend
|
||||
- Use PDFBox for PDF manipulation
|
||||
- Implement proper error handling and logging
|
||||
- Add Swagger documentation to all API endpoints
|
||||
- Use service layer for business logic
|
||||
- Follow Spring Boot best practices
|
||||
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
|
||||
|
||||
### General
|
||||
- Write clear commit messages
|
||||
- Update documentation for any API changes
|
||||
- Test in Docker before submitting PRs
|
||||
- Run `./gradlew spotlessApply` to format code
|
||||
- Ensure all tests pass with `./test.sh`
|
||||
```html
|
||||
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
|
||||
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
|
||||
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
|
||||
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
|
||||
```
|
||||
|
||||
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
|
||||
|
||||
+27
-149
@@ -8,66 +8,36 @@
|
||||
|
||||
Fork Stirling-PDF and create a new branch out of `main`.
|
||||
|
||||
## Add Language to i18n Configuration
|
||||
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
|
||||
|
||||
Edit the file: [frontend/src/i18n.ts](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/src/i18n.ts)
|
||||
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
|
||||
|
||||
Add your language to the `supportedLanguages` object. For example, to add Polish:
|
||||
|
||||
```typescript
|
||||
export const supportedLanguages = {
|
||||
'en': 'English',
|
||||
'en-GB': 'English (UK)',
|
||||
// ... other languages ...
|
||||
'pl-PL': 'Polski', // Add your language here
|
||||
};
|
||||
For example, to add Polish, you would add:
|
||||
|
||||
```html
|
||||
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
|
||||
```
|
||||
|
||||
If your language uses right-to-left (RTL) text direction, also add it to the `rtlLanguages` array:
|
||||
The `data-bs-language-code` is the code used to reference the file in the next step.
|
||||
|
||||
```typescript
|
||||
export const rtlLanguages = ['ar-AR', 'fa-IR', 'pl-PL']; // Add if RTL
|
||||
```
|
||||
### Add Language Property File
|
||||
|
||||
## Create Translation Directory
|
||||
Start by copying the existing English property file:
|
||||
|
||||
Create a new directory for your language in `frontend/public/locales/`. For Polish, this would be:
|
||||
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
|
||||
|
||||
```bash
|
||||
mkdir -p frontend/public/locales/pl-PL
|
||||
```
|
||||
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
|
||||
|
||||
## Add Translation File
|
||||
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
|
||||
|
||||
Start by copying the existing English (UK) translation file:
|
||||
|
||||
- [frontend/public/locales/en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.json)
|
||||
|
||||
Copy and rename it to `frontend/public/locales/{your-language-code}/translation.json`. In the Polish example:
|
||||
|
||||
```bash
|
||||
cp frontend/public/locales/en-GB/translation.json frontend/public/locales/pl-PL/translation.json
|
||||
```
|
||||
|
||||
Then translate all entries within that JSON file. The file uses nested JSON structure like:
|
||||
|
||||
```json
|
||||
{
|
||||
"addPageNumbers": {
|
||||
"title": "Add Page Numbers",
|
||||
"submit": "Add Page Numbers",
|
||||
"error": {
|
||||
"failed": "Add page numbers operation failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
If you do not have a Java IDE, I am happy to verify that the changes work once you raise the PR (but I won't be able to verify the translations themselves).
|
||||
|
||||
## Handling Untranslatable Strings
|
||||
|
||||
Sometimes, certain strings may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
|
||||
Sometimes, certain strings in the properties file may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
|
||||
|
||||
For example, if the English string for "error" does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
|
||||
For example, if the English string `error=Error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
|
||||
|
||||
```toml
|
||||
[pl_PL]
|
||||
@@ -80,119 +50,27 @@ ignore = [
|
||||
## Add New Translation Tags
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you add any new translation tags, they must first be added to the `frontend/public/locales/en-GB/translation.json` file. This ensures consistency across all language files.
|
||||
> If you add any new translation tags, they must first be added to the `messages_en_GB.properties` file. This ensures consistency across all language files.
|
||||
|
||||
- New translation tags **must be added** to the `en-GB` translation file to maintain a reference for other languages.
|
||||
- After adding the new tags to the en-GB file, add and translate them in the respective language file (e.g., `pl-PL/translation.json`).
|
||||
- New translation tags **must be added** to the `messages_en_GB.properties` file to maintain a reference for other languages.
|
||||
- After adding the new tags to `messages_en_GB.properties`, add and translate them in the respective language file (e.g., `messages_pl_PL.properties`).
|
||||
|
||||
Make sure to place the entry under the correct language section. This helps maintain the accuracy of translation progress statistics and ensures that the translation tool or scripts do not misinterpret the completion rate.
|
||||
|
||||
## Testing Your Translation
|
||||
### Use this code to perform a local check
|
||||
|
||||
### Start the development server
|
||||
#### Windows command
|
||||
|
||||
1. Start the frontend development server:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
```powershell
|
||||
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
|
||||
|
||||
2. The language selector should now include your new language
|
||||
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
|
||||
```
|
||||
|
||||
3. Select your language from the dropdown and verify all translations appear correctly
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
When adding a new language, you need to update:
|
||||
|
||||
- [ ] `frontend/src/i18n.ts` - Add to supportedLanguages (and rtlLanguages if needed)
|
||||
- [ ] `frontend/public/locales/{language-code}/translation.json` - Create and translate
|
||||
- [ ] `scripts/ignore_translation.toml` - Add untranslatable strings if needed
|
||||
|
||||
Then make a Pull Request (PR) into `main` for others to use!
|
||||
|
||||
If you do not have a Node.js environment, we are happy to verify that the changes work once you raise the PR (but we won't be able to verify the translations themselves).
|
||||
|
||||
## Translation Guidelines
|
||||
|
||||
- **Consistency**: Keep terminology consistent throughout the translation
|
||||
- **Context**: Consider the UI context when translating (e.g., button labels should be concise)
|
||||
- **Formatting**: Preserve placeholders like `{n}` or `{{count}}` in translations
|
||||
- **Testing**: Test your translations in the frontend interface
|
||||
- **RTL Languages**: If your language uses RTL, ensure you add it to the rtlLanguages array
|
||||
|
||||
## Advanced: Translation Management Scripts
|
||||
|
||||
For translators working on large translation files, Python scripts are available in `scripts/translations/` to help manage the workflow.
|
||||
|
||||
### Finding Untranslated Strings
|
||||
|
||||
To see which strings still need translation:
|
||||
#### Linux command
|
||||
|
||||
```bash
|
||||
# Check translation status for your language
|
||||
python scripts/translations/translation_analyzer.py --language pl-PL --summary
|
||||
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
|
||||
|
||||
# See detailed list of missing translations
|
||||
python scripts/translations/translation_analyzer.py --language pl-PL --missing-only
|
||||
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
|
||||
```
|
||||
|
||||
### Extracting Untranslated Strings
|
||||
|
||||
To extract only the strings that need translation into a separate file:
|
||||
|
||||
```bash
|
||||
# Extract to a compact JSON file
|
||||
python scripts/translations/compact_translator.py pl-PL --output to_translate.json
|
||||
```
|
||||
|
||||
This creates a file with just the untranslated entries:
|
||||
|
||||
```json
|
||||
{
|
||||
"addPageNumbers.title": "Add Page Numbers",
|
||||
"compress.header": "Compress PDF",
|
||||
"merge.submit": "Merge PDFs"
|
||||
}
|
||||
```
|
||||
|
||||
### Translating the Extracted File
|
||||
|
||||
Open `to_translate.json` and translate the values while keeping the keys unchanged:
|
||||
|
||||
```json
|
||||
{
|
||||
"addPageNumbers.title": "Dodaj numery stron",
|
||||
"compress.header": "Kompresuj PDF",
|
||||
"merge.submit": "Połącz pliki PDF"
|
||||
}
|
||||
```
|
||||
|
||||
### Merging Translations Back
|
||||
|
||||
After translating, merge your translations back into the main file:
|
||||
|
||||
```bash
|
||||
# Apply your translations
|
||||
python scripts/translations/translation_merger.py pl-PL apply-translations --translations-file to_translate.json
|
||||
|
||||
# Verify the result
|
||||
python scripts/translations/translation_analyzer.py --language pl-PL --summary
|
||||
```
|
||||
|
||||
### Validating Your Work
|
||||
|
||||
Before submitting, validate your translation file:
|
||||
|
||||
```bash
|
||||
# Check for JSON syntax errors
|
||||
python scripts/translations/json_validator.py frontend/public/locales/pl-PL/translation.json
|
||||
|
||||
# Check for missing placeholders
|
||||
python scripts/translations/validate_placeholders.py --language pl-PL
|
||||
|
||||
# Check for structural issues
|
||||
python scripts/translations/validate_json_structure.py --language pl-PL
|
||||
```
|
||||
|
||||
**Note**: These scripts require Python 3.7+ to be installed. See `scripts/translations/README.md` for detailed documentation.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user