Compare commits

..
Author SHA1 Message Date
Anthony Stirling b044c0dc98 bottom button test 2025-10-01 13:12:05 +01:00
Anthony StirlingandClaude 0c32dd53b2 fix: update mobile header branding to display icon and text logos
- Display both icon-only and text logos side by side in mobile header
- Use correct file paths: StirlingPDFLogoWhiteText/BlackText for text logos
- Add proper gap and sizing for both logo elements
- Remove single text span in favor of dual logo approach

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:37:41 +01:00
Anthony Stirling f4b2f2d5b8 Merge branch 'V2' into mobileSupport 2025-10-01 12:23:03 +01:00
Anthony StirlingandClaude 5d77373ef8 fix: improve mobile navigation and interaction
- Fix mobile view toggle buttons not working when clicked (use offsetWidth instead of clientWidth)
- Add flag to prevent scroll handler interference during programmatic scrolls
- Move Files button from header to bottom navigation bar
- Add bottom navigation bar with All Tools, Automate, and Files buttons
- Add RightRail to mobile workspace view
- Auto-switch to Tools view when clicking All Tools or Automate in mobile
- Style bottom bar buttons as full-width clickable areas with labels

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:22:00 +01:00
Anthony Stirling d854497266 Refine mobile header branding 2025-09-30 12:13:15 +01:00
Anthony Stirling 57ab30d1a6 Fix mobile files button click handler 2025-09-30 11:47:40 +01:00
Anthony Stirling 7dbf529a45 feat: add mobile slider layout for home page 2025-09-30 11:36:28 +01:00
234 changed files with 5128 additions and 22565 deletions
-5
View File
@@ -192,11 +192,6 @@ return useToolOperation({
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- Translation files are located in `frontend/public/locales/`
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![95%](https://geps.dev/progress/95) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![35%](https://geps.dev/progress/35) |
| Basque (Euskara) (eu_ES) | ![20%](https://geps.dev/progress/20) |
| Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) |
| Catalan (Català) (ca_CA) | ![37%](https://geps.dev/progress/37) |
| Croatian (Hrvatski) (hr_HR) | ![34%](https://geps.dev/progress/34) |
| Czech (Česky) (cs_CZ) | ![38%](https://geps.dev/progress/38) |
| Danish (Dansk) (da_DK) | ![34%](https://geps.dev/progress/34) |
| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) |
| Arabic (العربية) (ar_AR) | ![37%](https://geps.dev/progress/37) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![37%](https://geps.dev/progress/37) |
| Basque (Euskara) (eu_ES) | ![21%](https://geps.dev/progress/21) |
| Bulgarian (Български) (bg_BG) | ![40%](https://geps.dev/progress/40) |
| Catalan (Català) (ca_CA) | ![39%](https://geps.dev/progress/39) |
| Croatian (Hrvatski) (hr_HR) | ![36%](https://geps.dev/progress/36) |
| Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) |
| Danish (Dansk) (da_DK) | ![35%](https://geps.dev/progress/35) |
| Dutch (Nederlands) (nl_NL) | ![35%](https://geps.dev/progress/35) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![95%](https://geps.dev/progress/95) |
| Greek (Ελληνικά) (el_GR) | ![38%](https://geps.dev/progress/38) |
| Hindi (हिंदी) (hi_IN) | ![38%](https://geps.dev/progress/38) |
| Hungarian (Magyar) (hu_HU) | ![42%](https://geps.dev/progress/42) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![34%](https://geps.dev/progress/34) |
| Irish (Gaeilge) (ga_IE) | ![38%](https://geps.dev/progress/38) |
| Italian (Italiano) (it_IT) | ![95%](https://geps.dev/progress/95) |
| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) |
| Korean (한국어) (ko_KR) | ![38%](https://geps.dev/progress/38) |
| Norwegian (Norsk) (no_NB) | ![36%](https://geps.dev/progress/36) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![40%](https://geps.dev/progress/40) |
| Portuguese (Português) (pt_PT) | ![38%](https://geps.dev/progress/38) |
| Portuguese Brazilian (Português) (pt_BR) | ![95%](https://geps.dev/progress/95) |
| Romanian (Română) (ro_RO) | ![32%](https://geps.dev/progress/32) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) |
| Simplified Chinese (简体中文) (zh_CN) | ![96%](https://geps.dev/progress/96) |
| Slovakian (Slovensky) (sk_SK) | ![28%](https://geps.dev/progress/28) |
| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) |
| Spanish (Español) (es_ES) | ![95%](https://geps.dev/progress/95) |
| Swedish (Svenska) (sv_SE) | ![37%](https://geps.dev/progress/37) |
| Thai (ไทย) (th_TH) | ![34%](https://geps.dev/progress/34) |
| French (Français) (fr_FR) | ![42%](https://geps.dev/progress/42) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) |
| Hindi (हिंदी) (hi_IN) | ![40%](https://geps.dev/progress/40) |
| Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![36%](https://geps.dev/progress/36) |
| Irish (Gaeilge) (ga_IE) | ![40%](https://geps.dev/progress/40) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![41%](https://geps.dev/progress/41) |
| Korean (한국어) (ko_KR) | ![40%](https://geps.dev/progress/40) |
| Norwegian (Norsk) (no_NB) | ![37%](https://geps.dev/progress/37) |
| Persian (فارسی) (fa_IR) | ![39%](https://geps.dev/progress/39) |
| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) |
| Portuguese (Português) (pt_PT) | ![40%](https://geps.dev/progress/40) |
| Portuguese Brazilian (Português) (pt_BR) | ![42%](https://geps.dev/progress/42) |
| Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) |
| Russian (Русский) (ru_RU) | ![43%](https://geps.dev/progress/43) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![43%](https://geps.dev/progress/43) |
| Simplified Chinese (简体中文) (zh_CN) | ![99%](https://geps.dev/progress/99) |
| Slovakian (Slovensky) (sk_SK) | ![30%](https://geps.dev/progress/30) |
| Slovenian (Slovenščina) (sl_SI) | ![41%](https://geps.dev/progress/41) |
| Spanish (Español) (es_ES) | ![98%](https://geps.dev/progress/98) |
| Swedish (Svenska) (sv_SE) | ![38%](https://geps.dev/progress/38) |
| Thai (ไทย) (th_TH) | ![35%](https://geps.dev/progress/35) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![42%](https://geps.dev/progress/42) |
| Turkish (Türkçe) (tr_TR) | ![41%](https://geps.dev/progress/41) |
| Ukrainian (Українська) (uk_UA) | ![40%](https://geps.dev/progress/40) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![31%](https://geps.dev/progress/31) |
| Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) |
| Turkish (Türkçe) (tr_TR) | ![43%](https://geps.dev/progress/43) |
| Ukrainian (Українська) (uk_UA) | ![42%](https://geps.dev/progress/42) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![32%](https://geps.dev/progress/32) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -87,8 +87,7 @@ public class AutoJobAspect {
},
timeout,
queueable,
resourceWeight,
trackProgress);
resourceWeight);
} else {
// Use retry logic
return executeWithRetries(
@@ -221,8 +220,7 @@ public class AutoJobAspect {
},
timeout,
queueable,
resourceWeight,
trackProgress);
resourceWeight);
}
/**
@@ -258,6 +258,12 @@ public class AppConfig {
return false;
}
@Bean(name = "GoogleDriveEnabled")
@Profile("default")
public boolean googleDriveEnabled() {
return false;
}
@Bean(name = "license")
@Profile("default")
public String licenseType() {
@@ -1,44 +0,0 @@
package stirling.software.common.context;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
/**
* Holds contextual information for the currently executing job. Backed by a {@link ThreadLocal} so
* worker threads can retrieve the job ID and progress tracking preference while processing
* asynchronous work dispatched by {@link stirling.software.common.service.JobExecutorService
* JobExecutorService}.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class JobContextHolder {
private static final ThreadLocal<String> JOB_ID = new ThreadLocal<>();
private static final ThreadLocal<Boolean> PROGRESS_ENABLED = new ThreadLocal<>();
/** Store context for the current thread. */
public static void setContext(String jobId, boolean progressEnabled) {
if (jobId == null) {
clear();
return;
}
JOB_ID.set(jobId);
PROGRESS_ENABLED.set(progressEnabled);
}
/** Get the job ID bound to the current thread, or {@code null} if none. */
public static String getJobId() {
return JOB_ID.get();
}
/** Whether progress tracking is enabled for the current job (defaults to {@code false}). */
public static boolean isProgressEnabled() {
Boolean enabled = PROGRESS_ENABLED.get();
return enabled != null && enabled;
}
/** Remove all context associated with the current thread. */
public static void clear() {
JOB_ID.remove();
PROGRESS_ENABLED.remove();
}
}
@@ -530,6 +530,7 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class CustomMetadata {
@@ -548,6 +549,26 @@ public class ApplicationProperties {
: producer;
}
}
@Data
public static class GoogleDrive {
private boolean enabled;
private String clientId;
private String apiKey;
private String appId;
public String getClientId() {
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
}
public String getApiKey() {
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
}
public String getAppId() {
return appId == null || appId.trim().isEmpty() ? "" : appId;
}
}
}
@Data
@@ -41,18 +41,6 @@ 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.
@@ -66,28 +54,11 @@ public class JobResult {
* @return A new JobResult
*/
public static JobResult createNew(String jobId) {
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;
return JobResult.builder()
.jobId(jobId)
.complete(false)
.createdAt(LocalDateTime.now())
.build();
}
/**
@@ -99,9 +70,6 @@ public class JobResult {
this.complete = true;
this.result = result;
this.completedAt = LocalDateTime.now();
if (trackProgress) {
updateProgressInternal(100, "Completed");
}
}
/**
@@ -113,9 +81,6 @@ public class JobResult {
this.complete = true;
this.error = error;
this.completedAt = LocalDateTime.now();
if (trackProgress) {
updateProgressInternal(100, error != null ? error : "Failed");
}
}
/**
@@ -127,9 +92,6 @@ public class JobResult {
this.complete = true;
this.resultFiles = new ArrayList<>(resultFiles);
this.completedAt = LocalDateTime.now();
if (trackProgress) {
updateProgressInternal(100, "Completed");
}
}
/**
@@ -199,26 +161,4 @@ public class JobResult {
public List<String> getNotes() {
return Collections.unmodifiableList(notes);
}
/**
* Update the progress information if tracking is enabled.
*
* @param percent The percent complete (0-100)
* @param message Optional descriptive message
*/
public void updateProgress(int percent, String message) {
if (!trackProgress) {
return;
}
updateProgressInternal(percent, message);
}
private void updateProgressInternal(int percent, String message) {
int clamped = Math.min(100, Math.max(0, percent));
this.progressPercent = clamped;
if (message != null && !message.isBlank()) {
this.progressMessage = message;
}
this.progressUpdatedAt = LocalDateTime.now();
}
}
@@ -20,7 +20,6 @@ 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;
@@ -66,7 +65,7 @@ public class JobExecutorService {
* @return The response
*/
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
return runJobGeneric(async, work, -1, false, 50, true);
return runJobGeneric(async, work, -1);
}
/**
@@ -79,7 +78,7 @@ public class JobExecutorService {
*/
public ResponseEntity<?> runJobGeneric(
boolean async, Supplier<Object> work, long customTimeoutMs) {
return runJobGeneric(async, work, customTimeoutMs, false, 50, true);
return runJobGeneric(async, work, customTimeoutMs, false, 50);
}
/**
@@ -97,8 +96,7 @@ public class JobExecutorService {
Supplier<Object> work,
long customTimeoutMs,
boolean queueable,
int resourceWeight,
boolean trackProgress) {
int resourceWeight) {
String jobId = UUID.randomUUID().toString();
// Store the job ID in the request for potential use by other components
@@ -140,8 +138,6 @@ 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(
@@ -149,18 +145,13 @@ public class JobExecutorService {
jobId,
resourceWeight);
taskManager.createTask(jobId, trackProgress);
Supplier<Object> contextualWork = withJobContext(jobId, enableProgress, work);
taskManager.createTask(jobId);
// Create a specialized wrapper that updates the TaskManager
Supplier<Object> wrappedWork =
() -> {
try {
if (enableProgress) {
taskManager.updateProgress(jobId, 5, null);
}
Object result = contextualWork.get();
Object result = work.get();
processJobResult(jobId, result);
return result;
} catch (Exception e) {
@@ -178,49 +169,24 @@ public class JobExecutorService {
// Return immediately with job ID
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else if (async) {
taskManager.createTask(jobId, trackProgress);
taskManager.createTask(jobId);
executor.execute(
() ->
runWithJobContext(
jobId,
enableProgress,
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms",
jobId,
timeoutToUse);
() -> {
try {
log.debug(
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
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());
}
}));
// 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());
}
});
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
} else {
@@ -228,9 +194,7 @@ public class JobExecutorService {
log.debug("Running sync job with timeout {} ms", timeoutToUse);
// Execute with timeout
Supplier<Object> contextualWork = withJobContext(jobId, enableProgress, work);
Object result =
executeWithTimeout(contextualWork, timeoutToUse, jobId, enableProgress);
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
// If the result is already a ResponseEntity, return it directly
if (result instanceof ResponseEntity) {
@@ -488,14 +452,12 @@ 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, String jobId, boolean progressEnabled)
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
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(
withJobContext(jobId, progressEnabled, supplier), executor);
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
@@ -511,62 +473,4 @@ 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();
}
}
@@ -1,56 +0,0 @@
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);
}
}
@@ -1,81 +0,0 @@
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,17 +65,7 @@ public class TaskManager {
* @param jobId The job ID
*/
public void createTask(String 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));
jobResults.put(jobId, JobResult.createNew(jobId));
log.debug("Created task with job ID: {}", jobId);
}
@@ -175,11 +165,6 @@ 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);
}
@@ -224,33 +209,6 @@ public class TaskManager {
return false;
}
/**
* Update the progress information for a task.
*
* @param jobId The job ID
* @param percent Percentage complete (0-100)
* @param message Descriptive message for the current stage
* @return true if the progress update was accepted
*/
public boolean updateProgress(String jobId, int percent, String message) {
JobResult jobResult = jobResults.get(jobId);
if (jobResult == null) {
log.debug("Ignoring progress update for unknown job ID: {}", jobId);
return false;
}
if (!jobResult.isTrackProgress()) {
log.trace("Progress tracking disabled for job ID: {}", jobId);
return false;
}
jobResult.updateProgress(percent, message);
log.debug(
"Updated progress for job {} to {}% with message: {}",
jobId, jobResult.getProgressPercent(), message);
return true;
}
/**
* Get statistics about all jobs in the system
*
@@ -1,7 +1,6 @@
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;
@@ -73,8 +72,6 @@ class AutoJobPostMappingIntegrationTest {
@Captor private ArgumentCaptor<Integer> resourceWeightCaptor;
@Captor private ArgumentCaptor<Boolean> trackProgressCaptor;
@Test
void shouldExecuteWithCustomParameters() throws Throwable {
// Given
@@ -94,12 +91,7 @@ class AutoJobPostMappingIntegrationTest {
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
when(jobExecutorService.runJobGeneric(
anyBoolean(),
any(Supplier.class),
anyLong(),
anyBoolean(),
anyInt(),
anyBoolean()))
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
@@ -114,14 +106,12 @@ class AutoJobPostMappingIntegrationTest {
workCaptor.capture(),
timeoutCaptor.capture(),
queueableCaptor.capture(),
resourceWeightCaptor.capture(),
trackProgressCaptor.capture());
resourceWeightCaptor.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");
@@ -145,12 +135,7 @@ class AutoJobPostMappingIntegrationTest {
// Mock jobExecutorService to execute the work immediately
when(jobExecutorService.runJobGeneric(
anyBoolean(),
any(Supplier.class),
anyLong(),
anyBoolean(),
anyInt(),
anyBoolean()))
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenAnswer(
invocation -> {
Supplier<Object> work = invocation.getArgument(1);
@@ -165,16 +150,6 @@ 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
@@ -193,12 +168,7 @@ class AutoJobPostMappingIntegrationTest {
// Mock job executor to return a successful response
when(jobExecutorService.runJobGeneric(
anyBoolean(),
any(Supplier.class),
anyLong(),
anyBoolean(),
anyInt(),
anyBoolean()))
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
.thenReturn(ResponseEntity.ok("success"));
// When
@@ -109,6 +109,22 @@ class ApplicationPropertiesLogicTest {
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
@@ -94,7 +94,7 @@ class JobExecutorServiceTest {
assertNotNull(jobResponse.getJobId());
// Verify task manager was called
verify(taskManager).createTask(jobIdCaptor.capture(), eq(true));
verify(taskManager).createTask(jobIdCaptor.capture());
}
@Test
@@ -129,8 +129,7 @@ class JobExecutorServiceTest {
when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future);
// When
ResponseEntity<?> response =
jobExecutorService.runJobGeneric(true, work, 5000, true, 80, true);
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work, 5000, true, 80);
// Then
assertEquals(HttpStatus.OK, response.getStatusCode());
@@ -138,7 +137,7 @@ class JobExecutorServiceTest {
// Verify job was queued
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
verify(taskManager).createTask(anyString(), eq(true));
verify(taskManager).createTask(anyString());
}
@Test
@@ -1,83 +0,0 @@
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,9 +50,6 @@ class TaskManagerTest {
assertEquals(jobId, result.getJobId());
assertFalse(result.isComplete());
assertNotNull(result.getCreatedAt());
assertTrue(result.isTrackProgress());
assertEquals(0, result.getProgressPercent());
assertEquals("Pending", result.getProgressMessage());
}
@Test
@@ -71,8 +68,6 @@ class TaskManagerTest {
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
assertNotNull(result.getCompletedAt());
assertEquals(100, result.getProgressPercent());
assertEquals("Completed", result.getProgressMessage());
}
@Test
@@ -125,8 +120,6 @@ class TaskManagerTest {
assertTrue(result.isComplete());
assertEquals(errorMessage, result.getError());
assertNotNull(result.getCompletedAt());
assertEquals(100, result.getProgressPercent());
assertEquals(errorMessage, result.getProgressMessage());
}
@Test
@@ -145,7 +138,6 @@ class TaskManagerTest {
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals(resultObject, result.getResult());
assertEquals(100, result.getProgressPercent());
}
@Test
@@ -162,8 +154,6 @@ class TaskManagerTest {
assertNotNull(result);
assertTrue(result.isComplete());
assertEquals("Task completed successfully", result.getResult());
assertEquals(100, result.getProgressPercent());
assertEquals("Completed", result.getProgressMessage());
}
@Test
@@ -315,45 +305,4 @@ class TaskManagerTest {
// Assert
assertFalse(result);
}
@Test
void testCreateTaskWithoutProgressTracking() {
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId, false);
JobResult result = taskManager.getJobResult(jobId);
assertNotNull(result);
assertFalse(result.isTrackProgress());
assertNull(result.getProgressPercent());
assertNull(result.getProgressMessage());
}
@Test
void testUpdateProgress() {
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId);
boolean updated = taskManager.updateProgress(jobId, 50, "Halfway there");
assertTrue(updated);
JobResult result = taskManager.getJobResult(jobId);
assertEquals(50, result.getProgressPercent());
assertEquals("Halfway there", result.getProgressMessage());
assertNotNull(result.getProgressUpdatedAt());
}
@Test
void testUpdateProgressReturnsFalseWhenJobMissing() {
boolean updated = taskManager.updateProgress("missing", 10, "Stage");
assertFalse(updated);
}
@Test
void testUpdateProgressIgnoredWhenTrackingDisabled() {
String jobId = UUID.randomUUID().toString();
taskManager.createTask(jobId, false);
boolean updated = taskManager.updateProgress(jobId, 75, "Stage");
assertFalse(updated);
}
}
@@ -22,15 +22,12 @@ 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
@@ -92,22 +89,12 @@ 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;
}
@@ -147,24 +134,13 @@ 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 : pages) {
for (PDPage page : document.getPages()) {
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);
@@ -184,22 +160,10 @@ public class AnalysisController {
Map<String, Object> fontInfo = new HashMap<>();
Set<String> fontNames = new HashSet<>();
PDPageTree pages = document.getPages();
JobProgressTracker progressTracker =
jobProgressService.tracker(Math.max(1, pages.getCount()));
boolean trackProgress = progressTracker.isEnabled();
for (PDPage page : pages) {
for (PDPage page : document.getPages()) {
for (COSName font : page.getResources().getFontNames()) {
fontNames.add(font.getName());
}
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
fontInfo.put("fontCount", fontNames.size());
@@ -28,8 +28,6 @@ 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
@@ -39,7 +37,6 @@ 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(
@@ -71,31 +68,21 @@ 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,
sides,
progressTracker,
trackProgress);
flipOnShortEdge);
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
@@ -167,16 +154,14 @@ public class BookletImpositionController {
private PDDocument createSaddleBooklet(
PDDocument src,
int totalPages,
boolean addBorder,
String spineLocation,
boolean addGutter,
float gutterSize,
boolean doubleSided,
String duplexPass,
boolean flipOnShortEdge,
List<Side> sides,
JobProgressTracker progressTracker,
boolean trackProgress)
boolean flipOnShortEdge)
throws IOException {
PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src);
@@ -191,6 +176,8 @@ 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);
@@ -247,10 +234,6 @@ public class BookletImpositionController {
cellH,
addBorder);
}
if (trackProgress) {
progressTracker.advance();
}
}
return dst;
}
@@ -22,8 +22,6 @@ 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
@@ -31,7 +29,6 @@ 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
@@ -47,8 +44,6 @@ 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);
@@ -85,10 +80,6 @@ public class CropController {
request.getY(),
request.getWidth(),
request.getHeight()));
if (trackProgress) {
progressTracker.advance();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -96,10 +87,6 @@ public class CropController {
newDocument.close();
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
byte[] pdfContent = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
pdfContent,
@@ -37,8 +37,6 @@ 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;
@@ -50,7 +48,6 @@ 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 {
@@ -207,10 +204,6 @@ 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<>();
@@ -231,10 +224,6 @@ public class MergeController {
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
if (trackProgress) {
progressTracker.advance();
}
}
if (!invalidIndexes.isEmpty()) {
@@ -254,8 +243,6 @@ 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));
@@ -264,10 +251,6 @@ public class MergeController {
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
if (trackProgress) {
progressTracker.advance();
}
try {
mergerUtility.mergeDocuments(
pdfDocumentFactory.getStreamCacheFunction(
@@ -306,18 +289,10 @@ public class MergeController {
addTableOfContents(mergedDocument, files);
}
if (trackProgress) {
progressTracker.advance();
}
// Save the modified document to a new ByteArrayOutputStream
ByteArrayOutputStream baos = new ByteArrayOutputStream();
mergedDocument.save(baos);
if (trackProgress) {
progressTracker.complete();
}
String mergedFileName =
files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_merged_unsigned.pdf";
@@ -25,8 +25,6 @@ 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
@@ -34,7 +32,6 @@ 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
@@ -69,8 +66,6 @@ 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;
@@ -131,10 +126,6 @@ public class MultiPageLayoutController {
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.stroke();
}
if (trackProgress) {
progressTracker.advance();
}
}
contentStream.close(); // Close the final content stream
@@ -144,10 +135,6 @@ public class MultiPageLayoutController {
newDocument.save(baos);
newDocument.close();
if (trackProgress) {
progressTracker.complete();
}
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
@@ -17,8 +17,6 @@ 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;
/**
@@ -34,8 +32,6 @@ public class PdfImageRemovalController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
/**
* Endpoint to remove images from a PDF file.
*
@@ -58,13 +54,8 @@ 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, progressTracker);
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
// Create a ByteArrayOutputStream to hold the modified PDF data
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
@@ -78,10 +69,6 @@ public class PdfImageRemovalController {
file.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
+ "_removed_images.pdf";
if (trackProgress) {
progressTracker.complete();
}
// Convert the byte array to a web response and return it
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
}
@@ -27,8 +27,6 @@ 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;
@@ -37,7 +35,6 @@ 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
@@ -66,17 +63,13 @@ 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,
progressTracker);
tempFiles);
overlay.setInputPDF(basePdf);
if (overlayPos == 0) {
@@ -87,13 +80,6 @@ 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())
@@ -118,25 +104,18 @@ public class PdfOverlayController {
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount,
File[] overlayFiles,
String mode,
int[] counts,
List<File> tempFiles,
JobProgressTracker progressTracker)
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(
overlayGuide, overlayFiles, basePageCount, tempFiles, progressTracker);
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
break;
case "InterleavedOverlay":
interleavedOverlay(overlayGuide, overlayFiles, basePageCount, progressTracker);
interleavedOverlay(overlayGuide, overlayFiles, basePageCount);
break;
case "FixedRepeatOverlay":
fixedRepeatOverlay(
overlayGuide, overlayFiles, counts, basePageCount, progressTracker);
fixedRepeatOverlay(overlayGuide, overlayFiles, counts, basePageCount);
break;
default:
throw new IllegalArgumentException("Invalid overlay mode");
@@ -148,12 +127,10 @@ public class PdfOverlayController {
Map<Integer, String> overlayGuide,
File[] overlayFiles,
int basePageCount,
List<File> tempFiles,
JobProgressTracker progressTracker)
List<File> tempFiles)
throws IOException {
int overlayFileIndex = 0;
int pageCountInCurrentOverlay = 0;
boolean trackProgress = progressTracker.isEnabled();
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
if (pageCountInCurrentOverlay == 0
@@ -175,10 +152,6 @@ public class PdfOverlayController {
}
pageCountInCurrentOverlay++;
if (trackProgress) {
progressTracker.advance();
}
}
}
@@ -189,12 +162,8 @@ public class PdfOverlayController {
}
private void interleavedOverlay(
Map<Integer, String> overlayGuide,
File[] overlayFiles,
int basePageCount,
JobProgressTracker progressTracker)
Map<Integer, String> overlayGuide, File[] overlayFiles, int basePageCount)
throws IOException {
boolean trackProgress = progressTracker.isEnabled();
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
File overlayFile = overlayFiles[(basePageIndex - 1) % overlayFiles.length];
@@ -205,26 +174,17 @@ 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,
JobProgressTracker progressTracker)
Map<Integer, String> overlayGuide, File[] overlayFiles, int[] counts, int basePageCount)
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];
@@ -236,9 +196,6 @@ public class PdfOverlayController {
for (int page = 0; page < overlayPageCount; page++) {
if (currentPage > basePageCount) break;
overlayGuide.put(currentPage++, overlayFile.getAbsolutePath());
if (trackProgress) {
progressTracker.advance();
}
}
}
}
@@ -24,8 +24,6 @@ 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;
@@ -36,7 +34,6 @@ 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
@@ -62,20 +59,9 @@ 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,
@@ -286,30 +272,14 @@ 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,8 +19,6 @@ 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;
@@ -29,7 +27,6 @@ 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
@@ -54,19 +51,9 @@ public class RotationController {
// Get the list of pages in the document
PDPageTree pages = document.getPages();
int totalPages = Math.max(1, pages.getCount());
JobProgressTracker progressTracker = jobProgressService.tracker(totalPages);
boolean trackProgress = progressTracker.isEnabled();
for (PDPage page : pages) {
page.setRotation(page.getRotation() + angle);
if (trackProgress) {
progressTracker.advance();
}
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.pdfDocToWebResponse(
@@ -26,8 +26,6 @@ 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;
@@ -36,7 +34,6 @@ 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
@@ -58,8 +55,6 @@ 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();
@@ -92,10 +87,6 @@ public class ScalePagesController {
contentStream.restoreGraphicsState();
contentStream.close();
if (trackProgress) {
progressTracker.advance();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -103,10 +94,6 @@ public class ScalePagesController {
outputDocument.close();
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
@@ -28,8 +28,6 @@ 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;
@@ -39,7 +37,6 @@ 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
@@ -80,8 +77,6 @@ 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)) {
@@ -99,10 +94,6 @@ 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;
@@ -137,18 +128,11 @@ public class SplitPDFController {
throw e;
}
if (trackProgress) {
progressTracker.advance();
}
log.debug("Successfully created zip file with split documents: {}", zipFile.toString());
byte[] data = Files.readAllBytes(zipFile);
Files.deleteIfExists(zipFile);
// return the Resource in the response
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.bytesToWebResponse(
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
@@ -33,8 +33,6 @@ 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;
@@ -47,7 +45,6 @@ public class SplitPdfByChaptersController {
private final PdfMetadataService pdfMetadataService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
private static List<Bookmark> extractOutlineItems(
PDDocument sourceDocument,
@@ -181,12 +178,8 @@ 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, progressTracker);
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
@@ -197,14 +190,6 @@ 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 {
@@ -280,17 +265,13 @@ public class SplitPdfByChaptersController {
}
public List<ByteArrayOutputStream> getSplitDocumentsBoas(
PDDocument sourceDocument,
List<Bookmark> bookmarks,
boolean includeMetadata,
JobProgressTracker progressTracker)
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
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());
@@ -310,9 +291,6 @@ public class SplitPdfByChaptersController {
splitDocument.save(baos);
splitDocumentsBoas.add(baos);
if (trackProgress) {
progressTracker.advance();
}
} catch (Exception e) {
ExceptionUtils.logException("document splitting and saving", e);
throw e;
@@ -32,8 +32,6 @@ 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
@@ -41,7 +39,6 @@ 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
@@ -62,27 +59,15 @@ public class SplitPdfBySectionsController {
int horiz = request.getHorizontalDivisions() + 1;
int verti = request.getVerticalDivisions() + 1;
boolean merge = Boolean.TRUE.equals(request.getMerge());
int totalSections = sourceDocument.getNumberOfPages() * horiz * verti;
JobProgressTracker progressTracker = jobProgressService.tracker(totalSections + 1);
boolean trackProgress = progressTracker.isEnabled();
List<PDDocument> splitDocuments =
splitPdfPages(sourceDocument, verti, horiz, progressTracker);
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz);
String filename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
if (merge) {
MergeController mergeController = new MergeController(pdfDocumentFactory);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (trackProgress) {
progressTracker.advance();
}
mergeDocuments(splitDocuments).save(baos);
for (PDDocument doc : splitDocuments) {
doc.close();
}
if (trackProgress) {
progressTracker.complete();
}
mergeController.mergeDocuments(splitDocuments).save(baos);
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), filename + "_split.pdf");
}
for (PDDocument doc : splitDocuments) {
@@ -114,10 +99,6 @@ 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);
@@ -126,28 +107,11 @@ 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,
JobProgressTracker progressTracker)
PDDocument document, int horizontalDivisions, int verticalDivisions)
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();
@@ -187,10 +151,6 @@ public class SplitPdfBySectionsController {
}
splitDocuments.add(subDoc);
sectionCounter++;
if (trackProgress) {
progressTracker.advance();
}
}
}
}
@@ -25,8 +25,6 @@ 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;
@@ -37,7 +35,6 @@ 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
@@ -81,28 +78,21 @@ 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, progressTracker);
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
} 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, progressTracker);
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
} 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, progressTracker);
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
} else {
log.error("Invalid split type: {}", type);
throw ExceptionUtils.createIllegalArgumentException(
@@ -112,13 +102,6 @@ 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;
@@ -155,11 +138,7 @@ public class SplitPdfBySizeController {
}
private void handleSplitBySize(
PDDocument sourceDocument,
long maxBytes,
ZipOutputStream zipOut,
String baseFilename,
JobProgressTracker progressTracker)
PDDocument sourceDocument, long maxBytes, ZipOutputStream zipOut, String baseFilename)
throws IOException {
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
@@ -168,7 +147,6 @@ 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;
@@ -273,9 +251,6 @@ public class SplitPdfBySizeController {
}
}
}
if (trackProgress) {
progressTracker.setStepsCompleted(Math.min(totalPages, pageIndex + 1));
}
}
// Save final document if it has any pages
@@ -285,20 +260,13 @@ 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,
JobProgressTracker progressTracker)
PDDocument sourceDocument, int pageCount, ZipOutputStream zipOut, String baseFilename)
throws IOException {
log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
int currentPageCount = 0;
@@ -316,17 +284,12 @@ 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);
@@ -384,9 +347,6 @@ 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");
}
@@ -410,8 +370,7 @@ public class SplitPdfBySizeController {
PDDocument sourceDocument,
int documentCount,
ZipOutputStream zipOut,
String baseFilename,
JobProgressTracker progressTracker)
String baseFilename)
throws IOException {
log.debug("Starting handleSplitByDocCount with documentCount={}", documentCount);
int totalPageCount = sourceDocument.getNumberOfPages();
@@ -423,7 +382,6 @@ 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);
@@ -449,9 +407,6 @@ public class SplitPdfBySizeController {
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
currentPageIndex++;
if (trackProgress) {
progressTracker.setStepsCompleted(currentPageIndex);
}
} catch (Exception e) {
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
throw ExceptionUtils.createFileProcessingException("split", e);
@@ -22,8 +22,6 @@ 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
@@ -31,7 +29,6 @@ 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
@@ -48,10 +45,6 @@ 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;
@@ -86,9 +79,6 @@ public class ToSinglePageController {
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
yOffset -= page.getMediaBox().getHeight();
pageIndex++;
if (trackProgress) {
progressTracker.advance();
}
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -96,10 +86,6 @@ public class ToSinglePageController {
newDocument.close();
sourceDocument.close();
if (trackProgress) {
progressTracker.complete();
}
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
@@ -98,6 +98,11 @@ public class ConfigController {
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("GoogleDriveEnabled")) {
configData.put(
"GoogleDriveEnabled",
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -35,8 +35,6 @@ 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
@@ -44,7 +42,6 @@ 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
@@ -64,54 +61,28 @@ 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, progressTracker, trackProgress);
sanitizeJavaScript(document);
}
if (removeEmbeddedFiles) {
sanitizeEmbeddedFiles(document, progressTracker, trackProgress);
sanitizeEmbeddedFiles(document);
}
if (removeXMPMetadata) {
sanitizeXMPMetadata(document);
if (trackProgress) {
progressTracker.advance();
}
}
if (removeMetadata) {
sanitizeDocumentInfoMetadata(document);
if (trackProgress) {
progressTracker.advance();
}
}
if (removeLinks) {
sanitizeLinks(document, progressTracker, trackProgress);
sanitizeLinks(document);
}
if (removeFonts) {
sanitizeFonts(document, progressTracker, trackProgress);
}
if (trackProgress) {
progressTracker.complete();
sanitizeFonts(document);
}
return WebResponseUtils.pdfDocToWebResponse(
@@ -121,9 +92,7 @@ public class SanitizeController {
+ "_sanitized.pdf");
}
private void sanitizeJavaScript(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress)
throws IOException {
private void sanitizeJavaScript(PDDocument document) throws IOException {
// Get the root dictionary (catalog) of the PDF
PDDocumentCatalog catalog = document.getDocumentCatalog();
@@ -171,15 +140,10 @@ public class SanitizeController {
}
}
}
if (trackProgress) {
progressTracker.advance();
}
}
}
private void sanitizeEmbeddedFiles(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress) {
private void sanitizeEmbeddedFiles(PDDocument document) {
PDPageTree allPages = document.getPages();
for (PDPage page : allPages) {
@@ -187,9 +151,6 @@ public class SanitizeController {
if (res != null && res.getCOSObject() != null) {
res.getCOSObject().removeItem(COSName.getPDFName("EmbeddedFiles"));
}
if (trackProgress) {
progressTracker.advance();
}
}
}
@@ -210,9 +171,7 @@ public class SanitizeController {
}
}
private void sanitizeLinks(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress)
throws IOException {
private void sanitizeLinks(PDDocument document) throws IOException {
for (PDPage page : document.getPages()) {
for (PDAnnotation annotation : page.getAnnotations()) {
if (annotation != null && annotation instanceof PDAnnotationLink linkAnnotation) {
@@ -224,23 +183,16 @@ public class SanitizeController {
}
}
}
if (trackProgress) {
progressTracker.advance();
}
}
}
private void sanitizeFonts(
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress) {
private void sanitizeFonts(PDDocument document) {
for (PDPage page : document.getPages()) {
if (page != null
&& page.getResources() != null
&& page.getResources().getCOSObject() != null) {
page.getResources().getCOSObject().removeItem(COSName.getPDFName("Font"));
}
if (trackProgress) {
progressTracker.advance();
}
}
}
}
@@ -40,8 +40,6 @@ 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;
@@ -50,7 +48,6 @@ import stirling.software.common.util.WebResponseUtils;
public class WatermarkController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final JobProgressService jobProgressService;
@InitBinder
public void initBinder(WebDataBinder binder) {
@@ -103,10 +100,6 @@ 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
@@ -145,10 +138,6 @@ public class WatermarkController {
// Close the content stream
contentStream.close();
if (trackProgress) {
progressTracker.advance();
}
}
if (convertPdfToImage) {
@@ -157,10 +146,6 @@ public class WatermarkController {
document = convertedPdf;
}
if (trackProgress) {
progressTracker.complete();
}
return WebResponseUtils.pdfDocToWebResponse(
document,
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
@@ -11,8 +11,6 @@ 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 {
@@ -28,13 +26,6 @@ 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();
@@ -54,10 +45,6 @@ public class PdfImageRemovalService {
for (COSName name : namesToRemove) {
resources.put(name, (PDXObject) null);
}
if (trackProgress) {
progressTracker.advance();
}
}
return document;
}
@@ -1916,7 +1916,6 @@ fileManager.storageError=Storage error occurred
fileManager.storageLow=Storage is running low. Consider removing old files.
fileManager.uploadError=Failed to upload some files.
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
fileManager.loadingFiles=Loading files...
# Page Editor
pageEditor.title=Page Editor
@@ -1946,25 +1945,6 @@ 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
@@ -1977,39 +1957,3 @@ viewer.nextPage=Next Page
viewer.pageNavigation=Page Navigation
viewer.currentPage=Current Page
viewer.totalPages=Total Pages
toolPanel.legacy.favorites=Favourites
toolPanel.legacy.recent=Recently used
toolPanel.legacy.favorite=Add to favourites
toolPanel.legacy.unfavorite=Remove from favourites
toolPanel.legacy.settings.title=Customise appearance
toolPanel.legacy.settings.iconBackground.label=Tool icon background
toolPanel.legacy.settings.iconBackground.description=When to show coloured backgrounds behind tool icons
toolPanel.legacy.settings.iconBackground.none=None
toolPanel.legacy.settings.iconBackground.hover=On hover
toolPanel.legacy.settings.iconBackground.always=Always
toolPanel.legacy.settings.iconColor.label=Tool icon colour
toolPanel.legacy.settings.iconColor.description=Colour scheme for tool icons
toolPanel.legacy.settings.iconColor.colored=Coloured
toolPanel.legacy.settings.iconColor.vibrant=Vibrant
toolPanel.legacy.settings.iconColor.monochrome=Monochrome
toolPanel.legacy.settings.sectionTitle.label=Section titles
toolPanel.legacy.settings.sectionTitle.description=Colour for category section titles
toolPanel.legacy.settings.sectionTitle.colored=Coloured
toolPanel.legacy.settings.sectionTitle.neutral=Neutral
toolPanel.legacy.settings.headerIcon.label=Section header icons
toolPanel.legacy.settings.headerIcon.description=Colour for Favourites/Recent icons
toolPanel.legacy.settings.headerIcon.colored=Coloured
toolPanel.legacy.settings.headerIcon.monochrome=Monochrome
toolPanel.legacy.settings.headerBadge.label=Section header badges
toolPanel.legacy.settings.headerBadge.description=Colour for count badges in section headers
toolPanel.legacy.settings.headerBadge.colored=Coloured
toolPanel.legacy.settings.headerBadge.neutral=Neutral
toolPanel.legacy.settings.border.label=Tool item borders
toolPanel.legacy.settings.border.description=Show borders around tool items
toolPanel.legacy.settings.border.visible=Visible
toolPanel.legacy.settings.border.hidden=Hidden
toolPanel.legacy.settings.hover.label=Hover effect intensity
toolPanel.legacy.settings.hover.description=How prominent the hover effect should be
toolPanel.legacy.settings.hover.subtle=Subtle
toolPanel.legacy.settings.hover.moderate=Moderate
toolPanel.legacy.settings.hover.prominent=Prominent
@@ -76,6 +76,11 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
googleDrive:
enabled: false
clientId: ''
apiKey: ''
appId: ''
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
@@ -422,6 +422,10 @@
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
</div>
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
</div>
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
</div>
</div>
<div class="selected-files flex-wrap"></div>
@@ -439,4 +443,16 @@
</div>
</div>
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
<div th:if="${@GoogleDriveEnabled == true}" >
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
<script th:inline="javascript">
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
</script>
</div>
</th:block>
@@ -14,19 +14,17 @@ 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, mockJobProgressService);
sut = new RearrangePagesPDFController(mockPdfDocumentFactory);
}
/** Tests the behavior of the oddEvenMerge method when there are no pages in the document. */
@@ -22,14 +22,11 @@ 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;
@@ -45,16 +42,12 @@ public class RotationControllerTest {
PDDocument mockDocument = mock(PDDocument.class);
PDPageTree mockPages = mock(PDPageTree.class);
PDPage mockPage = mock(PDPage.class);
JobProgressTracker mockTracker = mock(JobProgressTracker.class);
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
when(mockDocument.getPages()).thenReturn(mockPages);
when(mockPages.getCount()).thenReturn(1);
when(mockPages.iterator())
.thenReturn(java.util.Collections.singletonList(mockPage).iterator());
when(mockPage.getRotation()).thenReturn(0);
when(jobProgressService.tracker(1)).thenReturn(mockTracker);
when(mockTracker.isEnabled()).thenReturn(false);
// Act
ResponseEntity<byte[]> response = rotationController.rotatePDF(request);
@@ -12,6 +12,7 @@ import org.springframework.core.annotation.Order;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@@ -54,6 +55,19 @@ public class EEAppConfig {
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
@Profile("security")
@Bean(name = "GoogleDriveEnabled")
@Primary
public boolean googleDriveEnabled() {
return runningProOrHigher()
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
}
@Bean(name = "GoogleDriveConfig")
public GoogleDrive googleDriveConfig() {
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
}
// TODO: Remove post migration
@SuppressWarnings("deprecation")
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
+1 -8
View File
@@ -50,14 +50,7 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
- **Custom Ports**: Modify port mappings in docker-compose files
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
## Development vs Production
- **Development**: Keep backend port 8080 exposed for debugging
- **Production**: Remove backend port exposure, use only frontend proxy
- **Production**: Remove backend port exposure, use only frontend proxy
-3
View File
@@ -47,9 +47,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
@@ -44,9 +44,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
-3
View File
@@ -46,9 +46,6 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
+2 -28
View File
@@ -1,18 +1,9 @@
// @ts-check
import eslint from '@eslint/js';
import globals from "globals";
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
];
const nodeGlobs = [
'scripts/**/*.{js,ts,mjs}',
'*.config.{js,ts,mjs}',
];
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
@@ -24,6 +15,7 @@ export default defineConfig(
},
{
rules: {
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
@@ -46,23 +38,5 @@ export default defineConfig(
},
],
},
},
// Config for browser scripts
{
files: srcGlobs,
languageOptions: {
globals: {
...globals.browser,
}
}
},
// Config for node scripts
{
files: nodeGlobs,
languageOptions: {
globals: {
...globals.node,
}
}
},
}
);
+4 -98
View File
@@ -41,7 +41,6 @@
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -54,7 +53,6 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -67,10 +65,6 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
@@ -1766,19 +1760,6 @@
"mlly": "^1.7.4"
}
},
"node_modules/@iconify/utils/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2014,28 +1995,6 @@
"react": "^18.x || ^19.x"
}
},
"node_modules/@maxim_mazurok/gapi.client.discovery-v1": {
"version": "0.4.20200806",
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz",
"integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/gapi.client": "*",
"@types/gapi.client.discovery-v1": "*"
}
},
"node_modules/@maxim_mazurok/gapi.client.drive-v3": {
"version": "0.1.20250930",
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20250930.tgz",
"integrity": "sha512-zNR7HtaFl2Pvf8Ck2zP8cppUst7ouY2isKn7hrGf6hQ4/0ULsu19qMRSQgRb0HxBYcGjak7kGK4pZI4a2z4CWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/gapi.client": "*",
"@types/gapi.client.discovery-v1": "*"
}
},
"node_modules/@mui/core-downloads-tracker": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
@@ -3622,54 +3581,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi": {
"version": "0.0.47",
"resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz",
"integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi.client": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz",
"integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/gapi.client.discovery-v1": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz",
"integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@maxim_mazurok/gapi.client.discovery-v1": "latest"
}
},
"node_modules/@types/gapi.client.drive-v3": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz",
"integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@maxim_mazurok/gapi.client.drive-v3": "latest"
}
},
"node_modules/@types/google.accounts": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
"integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/google.picker": {
"version": "0.0.51",
"resolved": "https://registry.npmjs.org/@types/google.picker/-/google.picker-0.0.51.tgz",
"integrity": "sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@@ -6504,9 +6415,10 @@
}
},
"node_modules/globals": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
"version": "15.15.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9993,12 +9905,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/signature_pad": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.1.tgz",
"integrity": "sha512-BT5JJygS5BS0oV+tffPRorIud6q17bM7v/1LdQwd0o6mTqGoI25yY1NjSL99OqkekWltS4uon6p52Y8j1Zqu7g==",
"license": "MIT"
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
-6
View File
@@ -37,7 +37,6 @@
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
@@ -50,7 +49,6 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -106,10 +104,6 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
File diff suppressed because it is too large Load Diff
+17 -117
View File
@@ -1,27 +1,9 @@
{
"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?",
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
"unsavedChangesTitle": "Unsaved Changes",
"keepWorking": "Keep Working",
"discardChanges": "Discard & Leave",
"applyAndContinue": "Save & Leave",
"discardChanges": "Discard Changes",
"applyAndContinue": "Apply & Continue",
"exportAndContinue": "Export & Continue",
"language": {
"direction": "ltr"
@@ -83,8 +65,6 @@
"save": "Save",
"saveToBrowser": "Save to Browser",
"download": "Download",
"pin": "Pin",
"unpin": "Unpin",
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
"undo": "Undo",
"moreOptions": "More Options",
@@ -275,33 +255,6 @@
"cacheInputs": {
"name": "Save form inputs",
"help": "Enable to store previously used inputs for future runs"
},
"general": {
"title": "General",
"description": "Configure general application preferences.",
"autoUnzip": "Auto-unzip API responses",
"autoUnzipDescription": "Automatically extract files from ZIP responses",
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
"autoUnzipFileLimit": "Auto-unzip file limit",
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
},
"hotkeys": {
"title": "Keyboard Shortcuts",
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
"errorModifier": {
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
},
"errorConflict": "Shortcut already used by {{tool}}.",
"none": "Not assigned",
"customBadge": "Custom",
"defaultLabel": "Default: {{shortcut}}",
"capturing": "Press keys… (Esc to cancel)",
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut",
"noShortcut": "No shortcut set"
}
},
"changeCreds": {
@@ -578,7 +531,7 @@
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colours/Contrast",
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
@@ -615,6 +568,11 @@
"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",
@@ -910,7 +868,6 @@
"rotate": {
"title": "Rotate PDF",
"submit": "Apply Rotation",
"selectRotation": "Select Rotation Angle (Clockwise)",
"error": {
"failed": "An error occurred while rotating the PDF."
},
@@ -1831,16 +1788,8 @@
"placeholder": "Enter your full name"
},
"instructions": {
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature."
"title": "How to add signature"
},
"mode": {
"move": "Move Signature",
"place": "Place Signature"
},
"updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
"deactivate": "Stop Placing Signatures",
"results": {
@@ -2562,15 +2511,11 @@
"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",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
"label": "Select Overlay PDF Files"
},
"mode": {
"label": "Select Overlay Mode",
@@ -2580,49 +2525,14 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"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."
}
}
"submit": "Submit"
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize,Customise",
@@ -2864,9 +2774,8 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
@@ -3153,12 +3062,7 @@
"panMode": "Pan Mode",
"rotateLeft": "Rotate Left",
"rotateRight": "Rotate Right",
"toggleSidebar": "Toggle Sidebar",
"exportSelected": "Export Selected Pages",
"toggleAnnotations": "Toggle Annotations Visibility",
"annotationMode": "Toggle Annotation Mode",
"draw": "Draw",
"save": "Save"
"toggleSidebar": "Toggle Sidebar"
},
"search": {
"title": "Search PDF",
@@ -3200,7 +3104,6 @@
"automate": "Automate",
"files": "Files",
"activity": "Activity",
"account": "Account",
"config": "Config",
"allTools": "All Tools"
},
@@ -3228,9 +3131,6 @@
"addFiles": "Add Files",
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
},
"fileEditor": {
"addFiles": "Add Files"
},
"fileManager": {
"title": "Upload PDF Files",
"subtitle": "Add files to your storage for easy access across tools",
@@ -3259,7 +3159,6 @@
"lastModified": "Last Modified",
"toolChain": "Tools Applied",
"restore": "Restore",
"unzip": "Unzip",
"searchFiles": "Search files...",
"recent": "Recent",
"localFiles": "Local Files",
@@ -3267,6 +3166,7 @@
"googleDriveShort": "Drive",
"myFiles": "My Files",
"noRecentFiles": "No recent files found",
"dropFilesHint": "Drop files here to upload",
"googleDriveNotAvailable": "Google Drive integration not available",
"openFiles": "Open Files",
"openFile": "Open File",
+6 -63
View File
@@ -2,23 +2,6 @@
"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",
@@ -579,7 +562,7 @@
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colors/Contrast",
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
@@ -1538,15 +1521,11 @@
"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",
"placeholder": "Choose PDF(s)...",
"addMore": "Add more PDFs..."
"label": "Select Overlay PDF Files"
},
"mode": {
"label": "Select Overlay Mode",
@@ -1556,49 +1535,14 @@
},
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)"
},
"position": {
"label": "Select Overlay Position",
"foreground": "Foreground",
"background": "Background"
},
"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."
}
}
"submit": "Submit"
},
"split-by-sections": {
"tags": "Section Split, Divide, Customize",
@@ -1768,9 +1712,8 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
@@ -38,7 +38,7 @@
"save": "Guardar",
"saveToBrowser": "Guardar en el navegador",
"close": "Cerrar",
"filesSelected": "{{count}} archivos seleccionados",
"filesSelected": "archivos seleccionados",
"noFavourites": "No se agregaron favoritos",
"downloadComplete": "Descarga completada",
"bored": "¿Aburrido de esperar?",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -23
View File
@@ -4,9 +4,7 @@ import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
@@ -42,27 +40,23 @@ export default function App() {
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<ErrorBoundary>
<PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>
+2 -30
View File
@@ -9,9 +9,6 @@ import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex';
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
import { loadScript } from '../utils/scriptLoader';
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -23,7 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { loadRecentFiles, handleRemoveFile } = useFileManager();
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
@@ -87,30 +84,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
};
}, []);
// Preload Google Drive scripts if configured
useEffect(() => {
if (isGoogleDriveConfigured()) {
// Load scripts in parallel without blocking
Promise.all([
loadScript({
src: 'https://apis.google.com/js/api.js',
id: 'gapi-script',
async: true,
defer: true,
}),
loadScript({
src: 'https://accounts.google.com/gsi/client',
id: 'gis-script',
async: true,
defer: true,
}),
]).catch((error) => {
console.warn('Failed to preload Google Drive scripts:', error);
});
}
}, []);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
@@ -127,7 +100,6 @@ 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',
@@ -151,6 +123,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
accept={{}}
multiple={true}
activateOnClick={false}
style={{
@@ -174,7 +147,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</FileManagerProvider>
@@ -1,8 +1,7 @@
import React, { useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import React, { useRef, useState, useCallback } from 'react';
import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
import { ColorSwatchButton } from './ColorPicker';
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
interface DrawingCanvasProps {
selectedColor: string;
@@ -12,7 +11,6 @@ interface DrawingCanvasProps {
onPenSizeChange: (size: number) => void;
onPenSizeInputChange: (input: string) => void;
onSignatureDataChange: (data: string | null) => void;
onDrawingComplete?: () => void;
disabled?: boolean;
width?: number;
height?: number;
@@ -29,253 +27,411 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
onDrawingComplete,
disabled = false,
width = 400,
height = 150,
modalWidth = 800,
modalHeight = 400,
additionalButtons
}) => {
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const visibleModalCanvasRef = useRef<HTMLCanvasElement>(null);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
const [isDrawing, setIsDrawing] = useState(false);
const [isModalDrawing, setIsModalDrawing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
padRef.current = new SignaturePad(canvas, {
penColor: selectedColor,
minWidth: penSize * 0.5,
maxWidth: penSize * 2.5,
throttle: 10,
minDistance: 5,
velocityFilterWeight: 0.7,
});
// Drawing functions for main canvas
const startDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || disabled) return;
setIsDrawing(true);
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
}
};
}, [disabled, selectedColor, penSize]);
const openModal = () => {
// Clear pad ref so it reinitializes
if (padRef.current) {
padRef.current.off();
padRef.current = null;
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !canvasRef.current || disabled) return;
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
setModalOpen(true);
};
}, [isDrawing, disabled]);
const trimCanvas = (canvas: HTMLCanvasElement): string => {
const ctx = canvas.getContext('2d');
if (!ctx) return canvas.toDataURL('image/png');
const stopDrawing = useCallback(() => {
if (!isDrawing || disabled) return;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
setIsDrawing(false);
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
// Save canvas as signature data
if (canvasRef.current) {
const dataURL = canvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
}
}, [isDrawing, disabled, onSignatureDataChange]);
// Find bounds of non-transparent pixels
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
if (alpha > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
// Modal canvas drawing functions
const startModalDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!visibleModalCanvasRef.current || !modalCanvasRef.current) return;
setIsModalDrawing(true);
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both the visible modal canvas and hidden canvas
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
}
});
}, [selectedColor, penSize]);
const drawModal = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isModalDrawing || !visibleModalCanvasRef.current || !modalCanvasRef.current) return;
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both canvases
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
});
}, [isModalDrawing]);
const stopModalDrawing = useCallback(() => {
if (!isModalDrawing) return;
setIsModalDrawing(false);
// Sync the canvases and update signature data (only when drawing stops)
if (modalCanvasRef.current) {
const dataURL = modalCanvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
// Also update the small canvas display
if (canvasRef.current) {
const smallCtx = canvasRef.current.getContext('2d');
if (smallCtx) {
const img = new Image();
img.onload = () => {
smallCtx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
smallCtx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
};
img.src = dataURL;
}
}
}
}, [isModalDrawing]);
const trimWidth = maxX - minX + 1;
const trimHeight = maxY - minY + 1;
// Clear canvas functions
const clearCanvas = useCallback(() => {
if (!canvasRef.current || disabled) return;
// Create trimmed canvas
const trimmedCanvas = document.createElement('canvas');
trimmedCanvas.width = trimWidth;
trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext('2d');
if (trimmedCtx) {
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// Also clear the modal canvas if it exists
if (modalCanvasRef.current) {
const modalCtx = modalCanvasRef.current.getContext('2d');
if (modalCtx) {
modalCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
}
}
onSignatureDataChange(null);
}
}, [disabled]);
const clearModalCanvas = useCallback(() => {
// Clear both modal canvases (visible and hidden)
if (modalCanvasRef.current) {
const hiddenCtx = modalCanvasRef.current.getContext('2d');
if (hiddenCtx) {
hiddenCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
}
}
return trimmedCanvas.toDataURL('image/png');
};
if (visibleModalCanvasRef.current) {
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
if (visibleCtx) {
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
}
}
const closeModal = () => {
if (padRef.current && !padRef.current.isEmpty()) {
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
onSignatureDataChange(trimmedPng);
// Also clear the main canvas and signature data
if (canvasRef.current) {
const mainCtx = canvasRef.current.getContext('2d');
if (mainCtx) {
mainCtx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
}
}
// Update preview canvas with proper aspect ratio
onSignatureDataChange(null);
}, []);
const saveModalSignature = useCallback(() => {
if (!modalCanvasRef.current) return;
const dataURL = modalCanvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
// Copy to small canvas for display
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
const img = new Image();
img.onload = () => {
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
const scale = Math.min(
previewCanvasRef.current.width / img.width,
previewCanvasRef.current.height / img.height
);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
}
}
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
};
img.src = trimmedPng;
img.src = dataURL;
}
}
if (onDrawingComplete) {
onDrawingComplete();
setIsModalOpen(false);
}, []);
const openModal = useCallback(() => {
setIsModalOpen(true);
// Copy content to modal canvas after a brief delay
setTimeout(() => {
if (visibleModalCanvasRef.current && modalCanvasRef.current) {
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
if (visibleCtx) {
visibleCtx.strokeStyle = selectedColor;
visibleCtx.lineWidth = penSize;
visibleCtx.lineCap = 'round';
visibleCtx.lineJoin = 'round';
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
visibleCtx.drawImage(modalCanvasRef.current, 0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
}
}
}
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(false);
};
}, 300);
}, [selectedColor, penSize]);
const clear = () => {
if (padRef.current) {
padRef.current.clear();
}
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
// Initialize canvas settings whenever color or pen size changes
React.useEffect(() => {
const updateCanvas = (canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
}
onSignatureDataChange(null);
};
};
const updatePenColor = (color: string) => {
if (padRef.current) {
padRef.current.penColor = color;
}
};
const updatePenSize = (size: number) => {
if (padRef.current) {
padRef.current.minWidth = size * 0.8;
padRef.current.maxWidth = size * 1.2;
}
};
updateCanvas(canvasRef.current);
updateCanvas(modalCanvasRef.current);
updateCanvas(visibleModalCanvasRef.current);
}, [selectedColor, penSize]);
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<Group justify="space-between">
<Text fw={500}>Draw your signature</Text>
<Group gap="lg">
<div>
<Text size="sm" fw={500} mb="xs" ta="center">Color</Text>
<Group justify="center">
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Group>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
disabled={disabled}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
<div style={{ paddingTop: '24px' }}>
<Button
variant="light"
size="compact-sm"
onClick={openModal}
disabled={disabled}
>
Expand
</Button>
</div>
</Group>
</Group>
<canvas
ref={previewCanvasRef}
ref={canvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
cursor: disabled ? 'default' : 'crosshair',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
</Text>
<Group justify="space-between">
<div>
{additionalButtons}
</div>
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={clearCanvas}
disabled={disabled}
>
Clear
</Button>
</Group>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
{/* Hidden canvas for modal synchronization */}
<canvas
ref={modalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{ display: 'none' }}
/>
{/* Modal for larger signature canvas */}
<Modal
opened={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Draw Your Signature"
size="xl"
centered
>
<Stack gap="md">
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<Popover
opened={colorPickerOpen}
onChange={setColorPickerOpen}
position="bottom-start"
withArrow
withinPortal={false}
>
<Popover.Target>
<div>
<ColorSwatchButton
color={selectedColor}
onClick={() => setColorPickerOpen(!colorPickerOpen)}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={(color) => {
onColorSwatchClick();
updatePenColor(color);
}}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
/>
</Popover.Dropdown>
</Popover>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={(size) => {
onPenSizeChange(size);
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</div>
{/* Color and Pen Size picker */}
<Paper withBorder p="sm">
<Group gap="lg" align="flex-end">
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</Group>
</Paper>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
<Paper withBorder p="md">
<canvas
ref={visibleModalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'crosshair',
backgroundColor: '#ffffff',
width: '100%',
maxWidth: `${modalWidth}px`,
height: 'auto',
}}
onMouseDown={startModalDrawing}
onMouseMove={drawModal}
onMouseUp={stopModalDrawing}
onMouseLeave={stopModalDrawing}
/>
</Paper>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
<Group justify="space-between">
<Button
variant="subtle"
color="red"
onClick={clearModalCanvas}
>
Clear Canvas
</Button>
<Button onClick={closeModal}>
Done
</Button>
</div>
<Group gap="sm">
<Button
variant="subtle"
onClick={() => setIsModalOpen(false)}
>
Cancel
</Button>
<Button
onClick={saveModalSignature}
>
Save Signature
</Button>
</Group>
</Group>
</Stack>
</Modal>
</>
);
};
export default DrawingCanvas;
export default DrawingCanvas;
@@ -48,7 +48,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{hint || t('sign.image.hint', 'Upload an image of your signature')}
{hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')}
</Text>
</Stack>
);
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from './ColorPicker';
interface TextInputWithFontProps {
text: string;
@@ -10,8 +9,6 @@ interface TextInputWithFontProps {
onFontSizeChange: (size: number) => void;
fontFamily: string;
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
@@ -24,8 +21,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = '#000000',
onTextColorChange,
disabled = false,
label,
placeholder
@@ -33,7 +28,6 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// Sync font size input with prop changes
useEffect(() => {
@@ -48,7 +42,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{ value: 'Georgia', label: 'Georgia' },
];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48'];
return (
<Stack gap="sm">
@@ -72,101 +66,61 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
allowDeselect={false}
/>
{/* Font Size and Color */}
<Group grow>
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
{/* Font Size */}
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-72)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 200) {
onFontSizeChange(size);
}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 200) {
setFontSizeInput(fontSize.toString());
} else {
onFontSizeChange(size);
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Text Color Picker */}
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
rightSection={
<Box
style={{
width: 24,
height: 24,
backgroundColor: textColor,
border: '1px solid #ccc',
borderRadius: 4,
cursor: disabled ? 'default' : 'pointer'
}}
/>
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 72) {
onFontSizeChange(size);
}
/>
</Box>
)}
</Group>
{/* Color Picker Modal */}
{onTextColorChange && (
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor}
onColorChange={onTextColorChange}
/>
)}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 72) {
setFontSizeInput(fontSize.toString());
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Stack>
);
};
@@ -1,177 +0,0 @@
import React, { useRef, useState } from 'react';
import { Button, Group, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import LocalIcon from '../shared/LocalIcon';
import { BASE_PATH } from '../../constants/app';
import styles from './FileEditor.module.css';
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
accept?: string;
multiple?: boolean;
}
const AddFileCard = ({
onFileSelect,
accept,
multiple = true
}: AddFileCardProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const [isUploadHover, setIsUploadHover] = useState(false);
const handleCardClick = () => {
openFilesModal();
};
const handleNativeUploadClick = (e: React.MouseEvent) => {
e.stopPropagation();
fileInputRef.current?.click();
};
const handleOpenFilesModal = (e: React.MouseEvent) => {
e.stopPropagation();
openFilesModal();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
onFileSelect(files);
}
// Reset input so same files can be selected again
event.target.value = '';
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t('fileEditor.addFiles', 'Add files')}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCardClick();
}
}}
>
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
</div>
<div className={styles.headerIndex}>
{t('fileEditor.addFiles', 'Add Files')}
</div>
<div className={styles.kebab} />
</div>
{/* Main content area */}
<div className={styles.addFileContent}>
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '100%',
marginTop: '0.8rem',
marginBottom: '0.8rem'
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
</div>
</div>
</>
);
};
export default AddFileCard;
@@ -34,9 +34,9 @@
.header {
height: 2.25rem;
border-radius: 0.0625rem 0.0625rem 0 0;
display: flex;
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
justify-content: space-between;
padding: 0 6px;
user-select: none;
background: var(--bg-toolbar);
@@ -86,23 +86,14 @@
}
.headerIndex {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.04em;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.headerIconButton {
.kebab {
justify-self: end;
color: #FFFFFF !important;
}
@@ -225,11 +216,6 @@
color: rgba(0, 0, 0, 0.35);
}
.pinned {
color: #FFC107 !important;
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
@@ -318,182 +304,4 @@
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3B4B6E;
}
/* =========================
Add File Card Styles
========================= */
.addFileCard {
background: var(--file-card-bg);
border: 2px dashed var(--border-default);
border-radius: 0.0625rem;
cursor: pointer;
transition: all 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: hidden;
margin-left: 0.5rem;
margin-right: 0.5rem;
opacity: 0.7;
}
.addFileCard:hover {
opacity: 1;
border-color: var(--color-blue-500);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.addFileCard:focus {
outline: 2px solid var(--color-blue-500);
outline-offset: 2px;
}
.addFileHeader {
background: var(--bg-subtle);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
.addFileCard:hover .addFileHeader {
background: var(--color-blue-500);
color: white;
}
.addFileContent {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
gap: 0.5rem;
}
.addFileIcon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 50%;
background: var(--bg-subtle);
transition: background-color 0.18s ease;
}
.addFileCard:hover .addFileIcon {
background: var(--color-blue-50);
}
.addFileText {
font-weight: 500;
transition: color 0.18s ease;
}
.addFileCard:hover .addFileText {
color: var(--text-primary);
}
.addFileSubtext {
font-size: 0.875rem;
opacity: 0.8;
}
/* -----------------------
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,14 +1,13 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import React, { useState, useCallback, useRef, useMemo } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import FileEditorThumbnail from './FileEditorThumbnail';
import AddFileCard from './AddFileCard';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId, StirlingFile } from '../../types/fileContext';
@@ -37,7 +36,6 @@ const FileEditor = ({
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions } = useFileActions();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
@@ -62,7 +60,7 @@ const FileEditor = ({
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
useEffect(() => {
React.useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
@@ -173,8 +171,8 @@ const FileEditor = ({
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Add files to context and select them automatically
await addFiles(allExtractedFiles, { selectFiles: true });
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
}
} catch (err) {
@@ -288,7 +286,7 @@ const FileEditor = ({
// File operations using context
const handleCloseFile = useCallback((fileId: FileId) => {
const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
@@ -310,48 +308,6 @@ const FileEditor = ({
}
}, [activeStirlingFileStubs, selectors, _setStatus]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await actions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: 'success',
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500
});
} else {
alert({
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
body: result.errors.join('\n'),
expandable: true,
durationMs: 3500
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
}
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
if (record) {
@@ -391,7 +347,7 @@ const FileEditor = ({
<Box pos="relative" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md">
<Box p="md" pt="xl">
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
@@ -449,14 +405,6 @@ const FileEditor = ({
pointerEvents: 'auto'
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
@@ -467,12 +415,11 @@ const FileEditor = ({
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
@@ -490,7 +437,7 @@ const FileEditor = ({
onSelectFiles={handleLoadFromStorage}
/>
</Box>
</Dropzone>
);
@@ -1,17 +1,15 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { alert } from '../toast';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import CloseIcon from '@mui/icons-material/Close';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '../../types/fileContext';
import { zipFileService } from '../../services/zipFileService';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
@@ -29,12 +27,11 @@ interface FileEditorThumbnailProps {
selectedFiles: FileId[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
@@ -44,20 +41,16 @@ const FileEditorThumbnail = ({
index,
selectedFiles,
onToggleFile,
onCloseFile,
onDeleteFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
const activeJobs = useMemo(() => file.activeJobs ?? [], [file.activeJobs]);
const visibleJobs = useMemo(() => activeJobs.filter(job => job.status !== 'completed'), [activeJobs]);
const hasActiveJobs = visibleJobs.length > 0;
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
@@ -71,9 +64,6 @@ const FileEditorThumbnail = ({
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const pageCount = file.processedFile?.totalPages || 0;
const handleRef = useRef<HTMLSpanElement | null>(null);
@@ -216,9 +206,6 @@ const FileEditorThumbnail = ({
};
const progressContainerBottom = file.toolHistory ? '56px' : '12px';
const toolChainBottom = hasActiveJobs ? '8px' : '4px';
return (
<div
ref={fileElementRef}
@@ -264,60 +251,18 @@ const FileEditorThumbnail = ({
{index + 1}
</div>
{/* Action buttons group */}
<div className={styles.headerActions}>
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
<ActionIcon
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</ActionIcon>
</Tooltip>
{/* Download icon */}
<Tooltip label={t('download', 'Download')}>
<ActionIcon
aria-label={t('download', 'Download')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
}}
>
<DownloadOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Actions overlay */}
@@ -342,7 +287,7 @@ const FileEditorThumbnail = ({
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
</button>
@@ -354,28 +299,18 @@ const FileEditorThumbnail = ({
<span>{t('download', 'Download')}</span>
</button>
{isZipFile && onUnzipFile && (
<button
className={styles.actionRow}
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<UnarchiveIcon fontSize="small" />
<span>{t('fileManager.unzip', 'Unzip')}</span>
</button>
)}
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
onDeleteFile(file.id);
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
setShowActions(false);
}}
>
<CloseIcon fontSize="small" />
<span>{t('close', 'Close')}</span>
<DeleteOutlineIcon fontSize="small" />
<span>{t('delete', 'Delete')}</span>
</button>
</div>
)}
@@ -389,7 +324,7 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
{file.name}
</Text>
<Text
@@ -415,7 +350,6 @@ const FileEditorThumbnail = ({
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<img
className="ph-no-capture"
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
@@ -442,52 +376,11 @@ 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>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
@@ -499,7 +392,7 @@ const FileEditorThumbnail = ({
{file.toolHistory && (
<div style={{
position: 'absolute',
bottom: toolChainBottom,
bottom: '4px',
left: '4px',
right: '4px',
padding: '4px 6px',
@@ -1,115 +0,0 @@
import React, { useState } from 'react';
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import LocalIcon from '../shared/LocalIcon';
import { BASE_PATH } from '../../constants/app';
const EmptyFilesState: React.FC = () => {
const { t } = useTranslation();
const { colorScheme } = useMantineColorScheme();
const { onLocalFileClick } = useFileManagerContext();
const [isUploadHover, setIsUploadHover] = useState(false);
const handleUploadClick = () => {
onLocalFileClick();
};
return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem'
}}
>
{/* Container */}
<div
style={{
backgroundColor: 'transparent',
padding: '3rem 2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
minWidth: '20rem',
maxWidth: '28rem',
width: '100%'
}}
>
{/* No Recent Files Message */}
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center" size="lg">
{t('fileManager.noRecentFiles', 'No recent files')}
</Text>
</Stack>
{/* Stirling PDF Logo */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Upload Button */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
marginTop: '0.5rem',
marginBottom: '0.5rem'
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--bg-file-manager)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: isUploadHover ? '2rem' : '1rem',
height: '38px',
width: isUploadHover ? '100%' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
}}
onClick={handleUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center' }}
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
</div>
</div>
);
};
export default EmptyFilesState;
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { 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
useEffect(() => {
React.useEffect(() => {
if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0);
}
@@ -44,7 +44,7 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
</Group>
<Box ml="md">
{sortedHistory.map((historyFile) => (
{sortedHistory.map((historyFile, _index) => (
<FileListItem
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
@@ -56,6 +56,7 @@ 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>
@@ -26,7 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
</Text>
@@ -1,10 +1,10 @@
import React from 'react';
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
import CloudIcon from '@mui/icons-material/Cloud';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import FileListItem from './FileListItem';
import FileHistoryGroup from './FileHistoryGroup';
import EmptyFilesState from './EmptyFilesState';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
interface FileListAreaProps {
@@ -29,7 +29,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
isLoading,
} = useFileManagerContext();
const { t } = useTranslation();
@@ -44,11 +43,15 @@ const FileListArea: React.FC<FileListAreaProps> = ({
scrollbarSize={8}
>
<Stack gap={0}>
{recentFiles.length === 0 && !isLoading ? (
<EmptyFilesState />
) : recentFiles.length === 0 && isLoading ? (
{recentFiles.length === 0 ? (
<Center style={{ height: '12.5rem' }}>
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
</Text>
</Stack>
</Center>
) : (
filteredFiles.map((file, index) => {
@@ -5,12 +5,10 @@ import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileId, StirlingFileStub } from '../../types/fileContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { zipFileService } from '../../services/zipFileService';
import ToolChain from '../shared/ToolChain';
interface FileListItemProps {
@@ -40,10 +38,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
@@ -98,7 +93,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center">
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
<Badge size="xs" variant="light" color={"blue"}>
v{currentVersion}
</Badge>
@@ -188,6 +183,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onAddToRecents(file);
}}
>
{t('fileManager.restore', 'Restore')}
@@ -196,22 +192,6 @@ const FileListItem: React.FC<FileListItemProps> = ({
</>
)}
{/* Unzip option for ZIP files */}
{isZipFile && !isHistoryFile && (
<>
<Menu.Item
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onUnzipFile(file);
}}
>
{t('fileManager.unzip', 'Unzip')}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
@@ -5,7 +5,6 @@ import UploadIcon from '@mui/icons-material/Upload';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -14,20 +13,8 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const handleGoogleDriveClick = async () => {
try {
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
onGoogleDriveSelect(files);
}
} catch (error) {
console.error('Failed to pick files from Google Drive:', error);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
@@ -80,24 +67,15 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
variant={buttonProps.variant('drive')}
leftSection={<CloudIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleGoogleDriveClick}
onClick={() => onSourceChange('drive')}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
disabled={!isGoogleDriveEnabled}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
}
}
}}
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
disabled
color={activeSource === 'drive' ? 'gray' : undefined}
styles={buttonProps.getStyles('drive')}
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
@@ -9,6 +9,7 @@ const HiddenFileInput: React.FC = () => {
ref={fileInputRef}
type="file"
multiple={true}
accept={["*/*"] as any}
onChange={onFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
@@ -1,58 +0,0 @@
import React from 'react';
import { HotkeyBinding } from '../../utils/hotkeys';
import { useHotkeys } from '../../contexts/HotkeyContext';
interface HotkeyDisplayProps {
binding: HotkeyBinding | null | undefined;
size?: 'sm' | 'md';
muted?: boolean;
}
const baseKeyStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '0.375rem',
background: 'var(--mantine-color-gray-1)',
border: '1px solid var(--mantine-color-gray-3)',
padding: '0.125rem 0.35rem',
fontSize: '0.75rem',
lineHeight: 1,
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
minWidth: '1.35rem',
color: 'var(--mantine-color-text)',
};
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
const { getDisplayParts } = useHotkeys();
const parts = getDisplayParts(binding);
if (!binding || parts.length === 0) {
return null;
}
const keyStyle = size === 'md'
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
: baseKeyStyle;
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
fontWeight: muted ? 500 : 600,
}}
>
{parts.map((part, index) => (
<React.Fragment key={`${part}-${index}`}>
<kbd style={keyStyle}>{part}</kbd>
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
</React.Fragment>
))}
</span>
);
};
export default HotkeyDisplay;
+4 -7
View File
@@ -146,12 +146,10 @@ export default function Workbench() {
}
>
{/* Top Controls */}
{activeFiles.length > 0 && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
/>
)}
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
/>
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
@@ -161,7 +159,6 @@ export default function Workbench() {
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
}}
>
{renderMainContent()}
@@ -317,7 +317,6 @@ const FileThumbnail = ({
>
{file.thumbnail && (
<img
className="ph-no-capture"
src={file.thumbnail}
alt={file.name}
draggable={false}
@@ -5,8 +5,6 @@ import { useNavigationGuard } from "../../contexts/NavigationContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
// Thumbnail generation is now handled by individual PageThumbnail components
import './PageEditor.module.css';
import PageThumbnail from './PageThumbnail';
@@ -526,38 +524,66 @@ const PageEditor = ({
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 2: Check if we have multiple documents (splits) or single document
if (Array.isArray(processedDocuments)) {
// Multiple documents (splits) - export as ZIP
const blobs: Blob[] = [];
const filenames: string[] = [];
// Step 3: Download
if (files.length > 1) {
// Multiple files - create ZIP
const sourceFiles = getSourceFiles();
const baseExportFilename = getExportFilename();
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
for (let i = 0; i < processedDocuments.length; i++) {
const doc = processedDocuments[i];
const partFilename = `${baseName}_part_${i + 1}.pdf`;
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
blobs.push(result.blob);
filenames.push(result.filename);
}
// Create ZIP file
const JSZip = await import('jszip');
const zip = new JSZip.default();
files.forEach((file) => {
zip.file(file.name, file);
blobs.forEach((blob, index) => {
zip.file(filenames[index], blob);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const exportFilename = getExportFilename();
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
} else {
// Single file - download directly
const file = files[0];
pdfExportService.downloadFile(file, file.name);
// Single document - regular export
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
processedDocuments,
sourceFiles,
[],
{ selectedOnly: false, filename: exportFilename }
)
: await pdfExportService.exportPDF(
processedDocuments,
[],
{ selectedOnly: false, filename: exportFilename }
);
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
@@ -566,39 +592,21 @@ const PageEditor = ({
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(async () => {
const applyChanges = useCallback(() => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
);
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
setEditedDocument(documentToSet);
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Apply changes failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
}, [displayDocument, mergedPdfDocument, splitPositions]);
const closePdf = useCallback(() => {
@@ -662,7 +670,7 @@ const PageEditor = ({
const displayedPages = displayDocument?.pages || [];
return (
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
@@ -785,7 +793,7 @@ const PageEditor = ({
<NavigationWarningModal
onApplyAndContinue={async () => {
await applyChanges();
applyChanges();
}}
onExportAndContinue={async () => {
await onExportAll();
@@ -371,11 +371,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
</div>
) : thumbnailUrl ? (
<img
className="ph-no-capture"
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
data-original-rotation={page.rotation}
style={{
width: '100%',
height: '100%',
@@ -17,34 +17,32 @@ export class RotatePageCommand extends DOMCommand {
}
execute(): void {
// Only update DOM for immediate visual feedback
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property to match the animated CSS
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let newRotation = currentRotation + this.degrees;
newRotation = ((newRotation % 360) + 360) % 360;
const newRotation = currentRotation + this.degrees;
img.style.transform = `rotate(${newRotation}deg)`;
}
}
}
undo(): void {
// Only update DOM
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let previousRotation = currentRotation - this.degrees;
previousRotation = ((previousRotation % 360) + 360) % 360;
const previousRotation = currentRotation - this.degrees;
img.style.transform = `rotate(${previousRotation}deg)`;
}
}
@@ -1,130 +0,0 @@
/* AppConfigModal styles */
.modal-container {
display: flex;
gap: 0;
height: 37.5rem; /* 600px */
}
.modal-nav {
width: 15rem; /* 240px */
height: 37.5rem; /* 600px */
border-top-left-radius: 0.75rem; /* 12px */
border-bottom-left-radius: 0.75rem; /* 12px */
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Mobile: compact icon-only navigation */
@media (max-width: 1024px) {
.modal-container {
height: 100vh !important;
max-height: none !important;
}
.modal-nav {
width: 5rem; /* 80px - wider for larger icons */
height: 100vh !important;
max-height: none !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.modal-nav-scroll {
padding: 1rem 0.5rem;
}
.modal-nav-section {
margin-bottom: 1.5rem;
}
.modal-nav-item.mobile {
padding: 1rem;
justify-content: center;
border-radius: 0.75rem;
margin-bottom: 0.75rem;
}
.modal-content {
height: 100vh !important;
max-height: none !important;
border-radius: 0;
}
}
.modal-nav-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: 2rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-nav-scroll::-webkit-scrollbar {
display: none;
}
.modal-nav-section {
margin-bottom: 1rem;
}
.modal-nav-section-items {
margin-top: 0.5rem;
}
.modal-nav-item {
cursor: pointer;
padding: 0.5rem 0.625rem; /* 8px 10px */
border-radius: 0.5rem; /* 8px */
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem; /* 4px */
}
.modal-content {
flex: 1;
height: 37.5rem; /* 600px */
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-content-scroll {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-content-scroll::-webkit-scrollbar {
display: none;
}
.modal-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
}
.modal-body {
padding: 2rem;
padding-top: 1rem;
}
.confirm-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.confirm-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
+115 -133
View File
@@ -1,12 +1,6 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import LocalIcon from './LocalIcon';
import Overview from './config/configSections/Overview';
import { createConfigNavSections } from './config/configNavSections';
import { NavKey } from './config/types';
import './AppConfigModal.css';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
import React from 'react';
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '../../hooks/useAppConfig';
interface AppConfigModalProps {
opened: boolean;
@@ -14,143 +8,131 @@ interface AppConfigModalProps {
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('overview');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config, loading, error, refetch } = useAppConfig();
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
}, []);
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
const colors = useMemo(() => ({
navBg: 'var(--modal-nav-bg)',
sectionTitle: 'var(--modal-nav-section-title)',
navItem: 'var(--modal-nav-item)',
navItemActive: 'var(--modal-nav-item-active)',
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
contentBg: 'var(--modal-content-bg)',
headerBorder: 'var(--modal-header-border)',
}), []);
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
const handleLogout = () => {
// In SaaS this would sign out, in open-source it does nothing
console.log('Logout placeholder for SaaS compatibility');
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
};
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
),
[]
);
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.label;
}
return '';
}, [configNavSections, active]);
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.component;
}
return null;
}, [configNavSections, active]);
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const premiumConfig = config ? {
premiumEnabled: config.premiumEnabled,
premiumKey: config.premiumKey ? '***hidden***' : null,
runningProOrHigher: config.runningProOrHigher,
runningEE: config.runningEE,
license: config.license,
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
const legalConfig = config ? {
termsAndConditions: config.termsAndConditions,
privacyPolicy: config.privacyPolicy,
cookiePolicy: config.cookiePolicy,
impressum: config.impressum,
accessibilityStatement: config.accessibilityStatement,
} : null;
return (
<Modal
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
title="App Configuration (Testing)"
size="lg"
style={{ zIndex: 1000 }}
>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map(item => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
This modal shows the current application configuration for testing purposes only.
</Text>
<Button size="xs" variant="light" onClick={refetch}>
Refresh
</Button>
</Group>
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">{activeLabel}</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
</div>
</div>
</div>
</div>
{loading && (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
)}
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
{config && (
<ScrollArea h={400}>
<Stack gap="lg">
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{renderConfigSection('Legal Configuration', legalConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
<Stack gap="xs">
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
<Code block style={{ fontSize: '11px' }}>
{JSON.stringify(config, null, 2)}
</Code>
</Stack>
</Stack>
</ScrollArea>
)}
</Stack>
</Modal>
);
};
-101
View File
@@ -1,101 +0,0 @@
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,7 +125,6 @@ const FilePickerModal = ({
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
zIndex={1100}
>
<Stack gap="md">
{storedFiles.length === 0 ? (
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
const FileUploadButton = ({
file,
onChange,
accept,
accept = "*/*",
disabled = false,
placeholder,
variant = "outline",
@@ -41,6 +41,7 @@ const LandingPage = () => {
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95%]"
style={{
@@ -177,6 +178,7 @@ const LandingPage = () => {
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
@@ -4,7 +4,6 @@ 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 {
@@ -210,7 +209,6 @@ 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,
@@ -266,15 +264,13 @@ 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">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
// Enable languages with >90% translation completion
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
const isDisabled = !enabledLanguages.includes(option.value);
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
const isDisabled = !isEnglishGB;
return (
<LanguageItem
@@ -1,19 +1,25 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useNavigationGuard } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
}
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const {
showNavigationWarning,
hasUnsavedChanges,
cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();
@@ -24,7 +30,7 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
confirmNavigation();
};
const handleApplyAndContinue = async () => {
const _handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
@@ -32,14 +38,13 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
confirmNavigation();
};
const _handleExportAndContinue = async () => {
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const BUTTON_WIDTH = "10rem";
if (!hasUnsavedChanges) {
return null;
@@ -51,53 +56,54 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
onClose={handleKeepWorking}
title={t("unsavedChangesTitle", "Unsaved Changes")}
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
size="lg"
closeOnClickOutside={false}
closeOnEscape={false}
>
<Stack>
<Stack ta="center" p="md">
<Text size="md" fw="300">
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
<Stack gap="md">
<Text>
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
</Text>
<Text size="lg" fw="500" >
{t("areYouSure", "Are you sure you want to leave?")}
</Text>
</Stack>
{/* Desktop layout: 2 groups side by side */}
<Group justify="space-between" gap="xl" visibleFrom="md">
<Group justify="space-between" gap="sm">
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
{t("discardChanges", "Discard Changes")}
</Button>
<Group gap="sm">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={handleKeepWorking}
>
{t("keepWorking", "Keep Working")}
</Button>
</Group>
<Group gap="sm">
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
{/* TODO:: Add this back in when it works */}
{/* {onApplyAndContinue && (
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
>
{t("applyAndContinue", "Apply & Continue")}
</Button>
)} */}
{onExportAndContinue && (
<Button
onClick={handleExportAndContinue}
>
{t("exportAndContinue", "Export & Continue")}
</Button>
)}
</Group>
</Group>
{/* Mobile layout: centered stack of 4 buttons */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
</Stack>
</Stack>
</Modal>
);
@@ -12,8 +12,6 @@ import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import AppConfigModal from './AppConfigModal';
import { useAppConfig } from '../../hooks/useAppConfig';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -24,9 +22,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@@ -68,8 +65,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: (e: React.MouseEvent) => handleClick(e),
'aria-label': config.name
} : {
onClick: () => handleClick(),
'aria-label': config.name
onClick: () => handleClick()
})}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
@@ -99,10 +95,12 @@ 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" />,
@@ -113,7 +111,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
//},
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
@@ -152,8 +150,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
//},
{
id: 'config',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
name: t("quickAccess.config", "Config"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
type: 'modal',
onClick: () => {
@@ -219,7 +217,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div className="spacer" />
{/* Config button at the bottom */}
{buttonConfigs
{/* {buttonConfigs
.filter(config => config.id === 'config')
.map(config => (
<div key={config.id} className="flex flex-col items-center gap-1">
@@ -229,7 +227,6 @@ 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">
@@ -240,14 +237,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{config.name}
</span>
</div>
))}
))} */}
</div>
</div>
<AppConfigModal
{/* <AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
/> */}
</div>
);
});
+32 -49
View File
@@ -15,28 +15,22 @@ import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { SearchInterface } from '../viewer/SearchInterface';
import { ViewerContext } from '../../contexts/ViewerContext';
import { useSignature } from '../../contexts/SignatureContext';
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
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, allButtonsDisabled } = useRightRail();
const { buttons, actions } = 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, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
const { pageEditorFunctions } = useToolWorkflow();
// CSV input state for page selection
const [csvInput, setCsvInput] = useState<string>("");
@@ -181,19 +175,19 @@ export default function RightRail() {
}, [currentView]);
return (
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
<div className="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 portalTarget={document.body}>
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => actions[btn.id]?.()}
disabled={btn.disabled || allButtonsDisabled || disableForFullscreen}
disabled={btn.disabled}
>
{btn.icon}
</ActionIcon>
@@ -211,7 +205,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 portalTarget={document.body}>
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
@@ -219,7 +213,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
@@ -239,7 +233,7 @@ export default function RightRail() {
{/* Pan Mode */}
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
<ActionIcon
variant={isPanning ? "filled" : "subtle"}
color={isPanning ? "blue" : undefined}
@@ -249,14 +243,14 @@ export default function RightRail() {
viewerContext?.panActions.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<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 portalTarget={document.body}>
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -264,14 +258,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateBackward();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<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 portalTarget={document.body}>
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -279,14 +273,14 @@ export default function RightRail() {
onClick={() => {
viewerContext?.rotationActions.rotateForward();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<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 portalTarget={document.body}>
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -294,17 +288,11 @@ export default function RightRail() {
onClick={() => {
viewerContext?.toggleThumbnailSidebar();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls
currentView={currentView}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
/>
</div>
<Divider className="right-rail-divider" />
</div>
@@ -316,14 +304,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 portalTarget={document.body}>
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleSelectAll}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems || allButtonsDisabled || disableForFullscreen}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
>
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -331,14 +319,14 @@ export default function RightRail() {
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleDeselectAll}
disabled={currentView === 'viewer' || selectedCount === 0 || allButtonsDisabled || disableForFullscreen}
disabled={currentView === 'viewer' || selectedCount === 0}
>
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -347,7 +335,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 portalTarget={document.body}>
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<Popover position="left" withArrow shadow="md" offset={8}>
@@ -357,7 +345,7 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0 || allButtonsDisabled || disableForFullscreen}
disabled={!pageControlsVisible || totalItems === 0}
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" />
@@ -384,7 +372,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 portalTarget={document.body}>
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
@@ -393,7 +381,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
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" />
@@ -406,7 +394,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 portalTarget={document.body}>
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
@@ -414,7 +402,7 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
@@ -425,7 +413,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 portalTarget={document.body}>
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
@@ -435,8 +423,7 @@ export default function RightRail() {
disabled={
currentView === 'viewer' ||
(currentView === 'fileEditor' && selectedCount === 0) ||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) ||
allButtonsDisabled || disableForFullscreen
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
}
>
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
@@ -450,8 +437,7 @@ 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 portalTarget={document.body}
>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@@ -462,17 +448,13 @@ export default function RightRail() {
</ActionIcon>
</Tooltip>
<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>
<LanguageSelector position="left-start" offset={6} compact />
<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 portalTarget={document.body}>
} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
@@ -480,7 +462,7 @@ export default function RightRail() {
className="right-rail-icon"
onClick={handleExportAll}
disabled={
disableForFullscreen || (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
@@ -495,3 +477,4 @@ export default function RightRail() {
);
}
@@ -1,34 +0,0 @@
import React from "react";
interface ToolIconProps {
icon: React.ReactNode;
opacity?: number;
color?: string;
marginRight?: string;
}
/**
* Shared icon component for consistent tool icon styling across the application.
* Uses the same visual pattern as ToolButton: scaled to 0.8, centered transform, consistent spacing.
*/
export const ToolIcon: React.FC<ToolIconProps> = ({
icon,
opacity = 1,
color = "var(--tools-text-and-icon-color)",
marginRight = "0.5rem"
}) => {
return (
<div
className="tool-button-icon"
style={{
color,
marginRight,
transform: "scale(0.8)",
transformOrigin: "center",
opacity
}}
>
{icon}
</div>
);
};
+10 -22
View File
@@ -8,7 +8,6 @@ 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;
@@ -29,10 +28,6 @@ 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> = ({
@@ -53,8 +48,6 @@ 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);
@@ -75,7 +68,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
const open = isControlled ? !!controlledOpen : internalOpen;
const setOpen = useCallback(
(newOpen: boolean) => {
@@ -155,16 +148,15 @@ 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, disabled]);
}, [clearTimers, setOpen, delay]);
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned && !disabled) openWithDelay();
if (!isPinned) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled]
[isPinned, openWithDelay, children.props]
);
const handlePointerLeave = useCallback(
@@ -227,10 +219,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned && !disabled && openOnFocus) openWithDelay();
if (!isPinned) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
[isPinned, openWithDelay, children.props]
);
const handleBlur = useCallback(
@@ -299,7 +291,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
left: coords.left,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
color: 'var(--text-primary)',
@@ -353,13 +345,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
return (
<>
{childWithHandlers}
{(() => {
const defaultTarget = typeof document !== 'undefined' ? document.body : null;
const target = portalTarget ?? defaultTarget;
return tooltipElement && target
? createPortal(tooltipElement, target)
: tooltipElement;
})()}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
</>
);
};
@@ -1,64 +0,0 @@
import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
export interface ConfigNavItem {
key: NavKey;
label: string;
icon: string;
component: React.ReactNode;
}
export interface ConfigNavSection {
title: string;
items: ConfigNavItem[];
}
export interface ConfigColors {
navBg: string;
sectionTitle: string;
navItem: string;
navItemActive: string;
navItemActiveBg: string;
contentBg: string;
headerBorder: string;
}
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
title: 'Account',
items: [
{
key: 'overview',
label: 'Overview',
icon: 'person-rounded',
component: <Overview onLogoutClick={onLogoutClick} />
},
],
},
{
title: 'Preferences',
items: [
{
key: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />
},
],
},
];
return sections;
};
@@ -1,108 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
</Stack>
);
};
export default GeneralSection;
@@ -1,204 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
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 { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
const rowStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
};
const rowHeaderStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
};
const HotkeysSection: React.FC = () => {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const [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;
}
pauseHotkeys();
return () => {
resumeHotkeys();
};
}, [editingTool, pauseHotkeys, resumeHotkeys]);
useEffect(() => {
if (!editingTool) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
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';
const fallbackText = isMac
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
return;
}
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
toolId !== editingTool && bindingEquals(existing, binding)
));
if (conflictEntry) {
const 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;
}
updateHotkey(editingTool, binding);
setEditingTool(null);
setError(null);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
};
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
const handleStartCapture = (toolId: ToolId) => {
setEditingTool(toolId);
setError(null);
};
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
<Text size="sm" c="dimmed">
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
</Text>
</div>
<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">
{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;
const defaultParts = getDisplayParts(defaultBinding);
const defaultLabel = defaultParts.length > 0
? defaultParts.join(' + ')
: t('settings.hotkeys.none', 'Not assigned');
return (
<React.Fragment key={toolId}>
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
<div style={rowHeaderStyle}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
<Text fw={600}>{tool.name}</Text>
<Group gap="xs" wrap="wrap" align="center">
<HotkeyDisplay binding={currentBinding} size="md" />
{!bindingEquals(currentBinding, defaultBinding) && (
<Badge variant="light" color="orange" radius="sm">
{t('settings.hotkeys.customBadge', 'Custom')}
</Badge>
)}
<Text size="xs" c="dimmed">
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
</Text>
</Group>
</div>
<Group gap="xs">
<Button
size="xs"
variant={isEditing ? 'filled' : 'default'}
color={isEditing ? 'blue' : undefined}
onClick={() => handleStartCapture(toolId)}
>
{isEditing
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
: t('settings.hotkeys.change', 'Change shortcut')}
</Button>
<Button
size="xs"
variant="subtle"
disabled={bindingEquals(currentBinding, defaultBinding)}
onClick={() => resetHotkey(toolId)}
>
{t('settings.hotkeys.reset', 'Reset')}
</Button>
</Group>
</div>
{isEditing && error && (
<Alert color="red" radius="sm" variant="filled">
{error}
</Alert>
)}
</Box>
{index < filteredTools.length - 1 && <Divider />}
</React.Fragment>
);
})
)}
</Stack>
</Paper>
</Stack>
);
};
export default HotkeysSection;
@@ -1,101 +0,0 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '../../../../hooks/useAppConfig';
const Overview: React.FC = () => {
const { config, loading, error } = useAppConfig();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const integrationConfig = config ? {
SSOAutoLogin: config.SSOAutoLogin,
} : null;
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
);
}
if (error) {
return (
<Alert color="red" title="Error">
{error}
</Alert>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
</div>
{config && (
<>
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
</>
)}
</Stack>
);
};
export default Overview;
@@ -1,19 +0,0 @@
export type NavKey =
| 'overview'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys';
// some of these are not used yet, but appear in figma designs
@@ -19,7 +19,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
children
}) => {
if (!file) return null;
const containerStyle = {
position: 'relative' as const,
cursor: onClick ? 'pointer' : 'default',
@@ -36,7 +36,6 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Image
className='ph-no-capture'
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
@@ -50,12 +49,11 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
<PictureAsPdfIcon
className='ph-no-capture'
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
<PictureAsPdfIcon
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
/>
</Center>
{children}
@@ -63,4 +61,4 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
);
};
export default DocumentThumbnail;
export default DocumentThumbnail;
@@ -46,13 +46,6 @@
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;
}
@@ -74,7 +67,7 @@
}
.right-rail-slot.visible {
max-height: 40rem; /* increased to fit additional controls + divider */
max-height: 18rem; /* increased to fit additional controls + divider */
opacity: 1;
}
@@ -84,14 +77,14 @@
opacity: 0;
}
100% {
max-height: 40rem;
max-height: 18rem;
opacity: 1;
}
}
@keyframes rightRailShrinkUp {
0% {
max-height: 40rem;
max-height: 18rem;
opacity: 1;
}
100% {
@@ -1,233 +0,0 @@
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '../LocalIcon';
import { Tooltip } from '../Tooltip';
import { ViewerContext } from '../../../contexts/ViewerContext';
import { useSignature } from '../../../contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '../../annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '../../../contexts/FileContext';
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { createProcessedFile } from '../../../contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
import { useNavigationState } from '../../../contexts/NavigationContext';
interface ViewerAnnotationControlsProps {
currentView: string;
disabled?: boolean;
}
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
}
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}
@@ -1,44 +0,0 @@
import React from 'react';
import { Slider, Text, Group, NumberInput } from '@mantine/core';
interface Props {
label: string;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
min?: number;
max?: number;
step?: number;
}
export default function SliderWithInput({
label,
value,
onChange,
disabled,
min = 0,
max = 200,
step = 1,
}: Props) {
return (
<div>
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text>
<Group gap="sm" align="center">
<div style={{ flex: 1 }}>
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
</div>
<NumberInput
value={value}
onChange={(v) => onChange(Number(v) || 0)}
min={min}
max={max}
step={step}
disabled={disabled}
style={{ width: 90 }}
/>
</Group>
</div>
);
}

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