Compare commits

...
Author SHA1 Message Date
Reece BrowneandGitHub e643fd5a9b Merge branch 'V2' into feature/sign-placement-ui 2025-11-19 12:50:18 +00:00
Anthony StirlingandGitHub 8ffe53536d activeUsers (#4936)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-19 12:35:35 +00:00
Anthony StirlingandGitHub 6177ccd333 update notif (#4937)
# Description of Changes
<img width="1521" height="1041" alt="image"
src="https://github.com/user-attachments/assets/2644bf70-0a9b-4c91-9046-02f555314608"
/>

<img width="1162" height="1598" alt="image"
src="https://github.com/user-attachments/assets/36d693f7-6fdd-4f2b-9db1-39ac336d9055"
/>

<img width="1220" height="1625" alt="image"
src="https://github.com/user-attachments/assets/4d4c19ea-0020-45fb-b15a-9f6ad377856c"
/>

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-19 11:54:33 +00:00
Reece BrowneandGitHub 521e0fd924 Merge branch 'V2' into feature/sign-placement-ui 2025-11-19 11:02:17 +00:00
87bf7a5b7f Add edit table of contents tool to React UI (#4917)
## Summary
- add a dedicated edit table of contents tool to the React UI, complete
with bookmark editor, import/export actions, and parameter handling
- register the tool in the translated registry and extend the English
translations with the new strings
- wire up the backend endpoints through a new operation hook and
form-data serialization helpers

## Testing
- ./gradlew build

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_691a4a87a9c4832899ecd1c55989f27f)

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-11-18 15:07:12 +00:00
Reece BrowneandGitHub 7e7469f6cf Merge branch 'V2' into feature/sign-placement-ui 2025-11-18 14:21:26 +00:00
Reece BrowneandGitHub a8ea0b60cf Chore/v2/fix auto zoom (#4903) 2025-11-18 13:43:52 +00:00
3deb9f3dbe Update frontend/src/core/tools/Sign.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-18 12:24:45 +00:00
Anthony StirlingandGitHub 15b8447626 translations (#4906)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-18 12:23:11 +00:00
James BruntonandGitHub a7fc36586a Fix OnboardingTour not responding to translations (#4934)
# Description of Changes
The onboarding tour wasn't presenting itself as having any dependencies
to React so would never be re-rendered after language changes.
2025-11-18 10:15:05 +00:00
James BruntonandGitHub a415c457e9 Add prompt to make Stirling your default PDF app (#4890)
# Description of Changes
- Adds a reusable banner component/system to the core app
- Adds banner at the top of the desktop app if Stirling isn't your
default PDF editor, with a button to make it your default
- Adds a permanent button in the settings to do it manually (in case
you've dismissed the banner)
- Simplifies the file loading logic to fix a bug where the input file
could be duplicated occasionally. Now, the TS just receives files from
one buffer, regardless of how they've been passed to the app in Rust.

## Caveats
I've only been able to get the setting of default apps working properly
on Mac. The Windows build isn't signed (yet) so we can't use the proper
API for it, so currently it just sends you to the Settings UI. I've also
not been able to test it on Linux at all.
2025-11-17 16:05:33 +00:00
Anthony StirlingandGitHub 28eb9baa02 Enable withCredentials for API client (#4921)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-17 14:07:58 +00:00
Anthony StirlingandGitHub 4b43693e29 csrf fixes (#4915)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-17 12:05:03 +00:00
Reece BrowneandGitHub 71dcbf9c12 Merge branch 'V2' into feature/sign-placement-ui 2025-11-14 13:52:47 +00:00
Reece BrowneandGitHub 8cca4ad538 Merge branch 'V2' into feature/sign-placement-ui 2025-11-14 13:05:24 +00:00
Reece 4a5f7e9c49 imports 2025-11-13 20:10:24 +00:00
Reece de4724a291 Escape key pause toggle 2025-11-13 20:03:16 +00:00
Reece 2c491c6bf4 More robust 2025-11-13 20:00:30 +00:00
Reece BrowneandGitHub 306117ed4b Merge branch 'V2' into feature/sign-placement-ui 2025-11-13 19:28:21 +00:00
Reece 1ea4b137f1 translations 2025-11-13 17:54:19 +00:00
Reece 3855a3ccc4 Revert Tooltip.tsx to match V2 - remove unrelated changes 2025-11-13 17:45:02 +00:00
Reece 3e198309f5 Remove tooltip stuff 2025-11-13 17:03:58 +00:00
Reece 2525e70abc tsc 2025-11-13 16:54:58 +00:00
Reece 7fe93674a0 - 2025-11-13 16:49:06 +00:00
Reece b978f1848a - 2025-11-13 16:48:08 +00:00
Reece 0692fe97d0 remove whitespace, retain signature, px to rem 2025-11-13 16:47:21 +00:00
Reece 365c0a7829 undo flattening 2025-11-13 16:14:00 +00:00
Reece 3179fc29d4 Merge branch 'feature/sign-placement-ui' of https://github.com/Stirling-Tools/Stirling-PDF into feature/sign-placement-ui 2025-11-13 14:15:00 +00:00
Reece 6a3fa31317 ? 2025-11-13 14:12:57 +00:00
Reece BrowneandGitHub 2fdae11a36 Merge branch 'V2' into feature/sign-placement-ui 2025-11-13 13:40:13 +00:00
Reece 75ff6ed441 Ux cleanup 2025-11-03 18:20:27 +00:00
Reece a86ece0473 Accidentally the whole thing 2025-11-03 17:54:37 +00:00
Reece f6063c1e74 Dedicated viewer actions 2025-10-31 20:32:16 +00:00
Reece fad16f6ed0 Merge branch 'feature/v2/viewer-improvements' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/viewer-improvements 2025-10-31 20:06:24 +00:00
Reece fe23952828 Viewer bridge handler. Reduce overengineering as flagged by copilot 2025-10-31 20:06:11 +00:00
Reece BrowneandGitHub 1767b755b6 Merge branch 'V2' into feature/v2/viewer-improvements 2025-10-31 18:37:28 +00:00
Reece 1bc8e7613f Viewer update and autozoom 2025-10-31 18:34:33 +00:00
116 changed files with 95808 additions and 92180 deletions
@@ -0,0 +1,37 @@
package stirling.software.common.annotations.api;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
• Admin workflows for issuing time-limited invitations to external users
• Self-service invite acceptance and team assignment
• License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}
@@ -0,0 +1,49 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
/**
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
* Only active when security is disabled (no-login mode).
*/
@Component
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
@RequiredArgsConstructor
@Slf4j
public class WAUTrackingFilter implements Filter {
private final WeeklyActiveUsersService wauService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest httpRequest) {
// Extract browser ID from header
String browserId = httpRequest.getHeader("X-Browser-Id");
if (browserId != null && !browserId.trim().isEmpty()) {
// Record browser access
wauService.recordBrowserAccess(browserId);
}
}
// Continue the filter chain
chain.doFilter(request, response);
}
}
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer {
.toArray(new String[0]);
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowedOriginPatterns(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else {
// Default to allowing all origins when nothing is configured
logger.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
}
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
// default)
}
}
@@ -29,7 +29,6 @@ import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -49,13 +48,12 @@ public class EditTableOfContentsController {
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
@RequestParam("file") MultipartFile file) throws Exception {
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
@@ -63,10 +61,10 @@ public class EditTableOfContentsController {
if (outline == null) {
log.info("No outline/bookmarks found in PDF");
return new ArrayList<>();
return ResponseEntity.ok(new ArrayList<>());
}
return extractBookmarkItems(document, outline);
return ResponseEntity.ok(extractBookmarkItems(document, outline));
} finally {
if (document != null) {
document.close();
@@ -154,6 +154,25 @@ public class ConfigController {
// EE features not available, continue without them
}
// Add version and machine info for update checking
try {
if (applicationContext.containsBean("appVersion")) {
configData.put(
"appVersion", applicationContext.getBean("appVersion", String.class));
}
if (applicationContext.containsBean("machineType")) {
configData.put(
"machineType", applicationContext.getBean("machineType", String.class));
}
if (applicationContext.containsBean("activeSecurity")) {
configData.put(
"activeSecurity",
applicationContext.getBean("activeSecurity", Boolean.class));
}
} catch (Exception e) {
// Version/machine info not available
}
return ResponseEntity.ok(configData);
} catch (Exception e) {
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
import stirling.software.common.annotations.api.InfoApi;
import stirling.software.common.model.ApplicationProperties;
@@ -34,6 +35,7 @@ public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private final Optional<WeeklyActiveUsersService> wauService;
private boolean metricsEnabled;
@PostConstruct
@@ -352,6 +354,35 @@ public class MetricsController {
return ResponseEntity.ok(formatDuration(uptime));
}
@GetMapping("/wau")
@Operation(
summary = "Weekly Active Users statistics",
description =
"Returns WAU (Weekly Active Users) count and total unique browsers. "
+ "Only available when security is disabled (no-login mode). "
+ "Tracks unique browsers via client-generated UUID in localStorage.")
public ResponseEntity<?> getWeeklyActiveUsers() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
// Check if WAU service is available (only when security.enableLogin=false)
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
Map<String, Object> wauStats = new HashMap<>();
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
wauStats.put("daysOnline", service.getDaysOnline());
wauStats.put("trackingSince", service.getStartTime().toString());
return ResponseEntity.ok(wauStats);
}
private String formatDuration(Duration duration) {
long days = duration.toDays();
long hours = duration.toHoursPart();
@@ -0,0 +1,100 @@
package stirling.software.SPDF.service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Service for tracking Weekly Active Users (WAU) in no-login mode.
* Uses in-memory storage with automatic cleanup of old entries.
*/
@Service
@Slf4j
public class WeeklyActiveUsersService {
// Map of browser ID -> last seen timestamp
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
// Application start time
private final Instant startTime = Instant.now();
/**
* Records a browser access with the current timestamp
* @param browserId Unique browser identifier from X-Browser-Id header
*/
public void recordBrowserAccess(String browserId) {
if (browserId == null || browserId.trim().isEmpty()) {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
}
}
/**
* Gets the count of unique browsers seen in the last 7 days
* @return Weekly Active Users count
*/
public long getWeeklyActiveUsers() {
cleanupOldEntries();
return activeBrowsers.size();
}
/**
* Gets the total count of unique browsers ever seen
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
}
/**
* Gets the number of days the service has been running
* @return Days online
*/
public long getDaysOnline() {
return ChronoUnit.DAYS.between(startTime, Instant.now());
}
/**
* Gets the timestamp when tracking started
* @return Start time
*/
public Instant getStartTime() {
return startTime;
}
/**
* Removes entries older than 7 days
*/
private void cleanupOldEntries() {
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/**
* Manual cleanup trigger (can be called by scheduled task if needed)
*/
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
int sizeAfter = activeBrowsers.size();
if (sizeBefore != sizeAfter) {
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
}
}
}
@@ -24,6 +24,7 @@ import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
@@ -86,9 +87,13 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -108,9 +113,13 @@ class EditTableOfContentsControllerTest {
when(mockCatalog.getDocumentOutline()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertTrue(result.isEmpty());
verify(mockDocument).close();
@@ -142,9 +151,13 @@ class EditTableOfContentsControllerTest {
when(childItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -178,9 +191,13 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -129,61 +129,53 @@ public class SecurityConfiguration {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
allowedOrigins);
// Set allowed methods explicitly (including OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// Set allowed headers explicitly
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN"));
// Set exposed headers (headers that the browser can access)
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
// Allow credentials (cookies, authorization headers)
cfg.setAllowCredentials(true);
// Set max age for preflight cache
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
configuredOrigins);
} else {
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
log.info(
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@UserApi
@InviteApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
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
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
+188 -4
View File
@@ -41,6 +41,30 @@
"cancel": "Cancel",
"confirm": "Extract"
},
"defaultApp": {
"title": "Set as Default PDF App",
"message": "Would you like to set Stirling PDF as your default PDF editor?",
"description": "You can change this later in your system settings.",
"notNow": "Not Now",
"setDefault": "Set Default",
"dismiss": "Dismiss",
"prompt": {
"title": "Set as Default PDF Editor",
"message": "Make Stirling PDF your default application for opening PDF files."
},
"success": {
"title": "Default App Set",
"message": "Stirling PDF is now your default PDF editor"
},
"settingsOpened": {
"title": "Settings Opened",
"message": "Please select Stirling PDF in your system settings"
},
"error": {
"title": "Error",
"message": "Failed to set default PDF handler"
}
},
"language": {
"direction": "ltr"
},
@@ -332,6 +356,20 @@
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
},
"defaultPdfEditor": "Default PDF editor",
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
"defaultPdfEditorInactive": "Another application is set as default",
"defaultPdfEditorChecking": "Checking...",
"defaultPdfEditorSet": "Already Default",
"setAsDefault": "Set as Default",
"updates": {
"title": "Software Updates",
"description": "Check for updates and view version information",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"checkForUpdates": "Check for Updates",
"viewDetails": "View Details"
}
},
"hotkeys": {
@@ -353,6 +391,37 @@
"searchPlaceholder": "Search tools..."
}
},
"update": {
"modalTitle": "Update Available",
"current": "Current Version",
"latest": "Latest Version",
"latestStable": "Latest Stable",
"priorityLabel": "Priority",
"recommendedAction": "Recommended Action",
"breakingChangesDetected": "Breaking Changes Detected",
"breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.",
"migrationGuides": "Migration Guides",
"viewGuide": "View Guide",
"loadingDetailedInfo": "Loading detailed information...",
"close": "Close",
"viewAllReleases": "View All Releases",
"downloadLatest": "Download Latest",
"availableUpdates": "Available Updates",
"unableToLoadDetails": "Unable to load detailed information.",
"version": "Version",
"urgentUpdateAvailable": "Urgent Update",
"updateAvailable": "Update Available",
"releaseNotes": "Release Notes",
"priority": {
"urgent": "Urgent",
"normal": "Normal",
"minor": "Minor",
"low": "Low"
},
"breakingChanges": "Breaking Changes",
"breakingChangesDefault": "This version contains breaking changes.",
"migrationGuide": "Migration Guide"
},
"changeCreds": {
"title": "Change Credentials",
"header": "Update Your Account Details",
@@ -1397,6 +1466,93 @@
},
"submit": "Change"
},
"editTableOfContents": {
"settings": {
"title": "Bookmarks & outline",
"replaceExisting": "Replace existing bookmarks (uncheck to append)",
"replaceExistingHint": "When disabled, the new outline is appended after the current bookmarks."
},
"actions": {
"source": "Load bookmarks",
"selectedFile": "Loaded from {{file}}",
"noFile": "Select a PDF to extract existing bookmarks.",
"loadFromPdf": "Load from selected PDF",
"importJson": "Import JSON",
"importClipboard": "Paste JSON from clipboard",
"export": "Export bookmarks",
"exportJson": "Download JSON",
"exportClipboard": "Copy JSON to clipboard",
"clipboardUnavailable": "Clipboard access is not available in this browser."
},
"info": {
"line1": "Each bookmark needs a descriptive title and the page it should open.",
"line2": "Use child bookmarks to build a hierarchy for chapters, sections, or subsections.",
"line3": "Import bookmarks from the selected PDF or from a JSON file to save time."
},
"workbench": {
"empty": {
"title": "Open the tool to start editing",
"description": "Select the Edit Table of Contents tool to load its workspace."
},
"tabTitle": "Outline workspace",
"subtitle": "Import bookmarks, build hierarchies, and apply the outline without cramped side panels.",
"noFile": "No PDF selected",
"fileLabel": "Changes will be applied to the currently selected PDF.",
"filePrompt": "Select a PDF from your library or upload a new one to begin.",
"changeFile": "Change PDF",
"selectFile": "Select PDF"
},
"editor": {
"heading": "Bookmark editor",
"description": "Add, nest, and reorder bookmarks to craft your PDF outline.",
"addTopLevel": "Add top-level bookmark",
"empty": {
"title": "No bookmarks yet",
"description": "Import existing bookmarks or start by adding your first entry.",
"action": "Add first bookmark"
},
"defaultTitle": "New bookmark",
"defaultChildTitle": "Child bookmark",
"defaultSiblingTitle": "New bookmark",
"untitled": "Untitled bookmark",
"childBadge": "Child",
"pagePreview": "Page {{page}}",
"field": {
"title": "Bookmark title",
"page": "Target page number"
},
"actions": {
"toggle": "Toggle children",
"addChild": "Add child bookmark",
"addSibling": "Add sibling bookmark",
"remove": "Remove bookmark"
},
"confirmRemove": "Remove this bookmark and all of its children?"
},
"messages": {
"loadedTitle": "Bookmarks extracted",
"loadedBody": "Existing bookmarks from the PDF were loaded into the editor.",
"noBookmarks": "No bookmarks were found in the selected PDF.",
"loadFailed": "Unable to extract bookmarks from the selected PDF.",
"imported": "Bookmarks imported",
"importedBody": "Your JSON outline replaced the current editor contents.",
"importedClipboard": "Clipboard data replaced the current bookmark list.",
"invalidJson": "Invalid JSON structure",
"invalidJsonBody": "Please provide a valid bookmark JSON file and try again.",
"exported": "JSON download ready",
"copied": "Copied to clipboard",
"copiedBody": "Bookmark JSON copied successfully.",
"copyFailed": "Copy failed"
},
"error": {
"failed": "Failed to update the table of contents"
},
"submit": "Apply table of contents",
"results": {
"title": "Updated PDF with bookmarks",
"subtitle": "Download the processed file or undo the operation below."
}
},
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
@@ -1978,9 +2134,23 @@
"title": "Draw your signature",
"clear": "Clear"
},
"canvas": {
"heading": "Draw your signature",
"clickToOpen": "Click to open the drawing canvas",
"modalTitle": "Draw your signature",
"colorLabel": "Colour",
"penSizeLabel": "Pen size",
"penSizePlaceholder": "Size",
"clear": "Clear canvas",
"colorPickerTitle": "Choose stroke colour"
},
"text": {
"name": "Signer Name",
"placeholder": "Enter your full name"
"placeholder": "Enter your full name",
"fontLabel": "Font",
"fontSizeLabel": "Font size",
"fontSizePlaceholder": "Type or select font size (8-200)",
"colorLabel": "Text colour"
},
"clear": "Clear",
"add": "Add",
@@ -2003,6 +2173,11 @@
"steps": {
"configure": "Configure Signature"
},
"step": {
"createDesc": "Choose how you want to create the signature",
"place": "Place & save",
"placeDesc": "Position the signature on your PDF"
},
"type": {
"title": "Signature Type",
"draw": "Draw",
@@ -2019,11 +2194,16 @@
"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."
"text": "After entering your name above, click anywhere on the PDF to place your signature.",
"paused": "Placement paused",
"resumeHint": "Resume placement to click and add your signature.",
"noSignature": "Create a signature above to enable placement tools."
},
"mode": {
"move": "Move Signature",
"place": "Place Signature"
"place": "Place Signature",
"pause": "Pause placement",
"resume": "Resume placement"
},
"updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
@@ -4622,6 +4802,9 @@
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
"colorPicker": {
"title": "Choose colour"
},
"common": {
"previous": "Previous",
"next": "Next",
@@ -4637,7 +4820,8 @@
"used": "used",
"available": "available",
"cancel": "Cancel",
"preview": "Preview"
"preview": "Preview",
"done": "Done"
},
"config": {
"overview": {
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
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
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
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
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
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
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
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
+11
View File
@@ -643,6 +643,15 @@ dependencies = [
"libc",
]
[[package]]
name = "core-services"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a"
dependencies = [
"core-foundation 0.10.1",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -3941,6 +3950,8 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"core-foundation 0.10.1",
"core-services",
"log",
"reqwest 0.11.27",
"serde",
+4
View File
@@ -31,3 +31,7 @@ tauri-plugin-fs = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-services = "1.0"
@@ -0,0 +1,217 @@
use crate::utils::add_log;
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::process::Command;
/// Check if Stirling PDF is the default PDF handler
#[tauri::command]
pub fn is_default_pdf_handler() -> Result<bool, String> {
add_log("🔍 Checking if app is default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
check_default_windows()
}
#[cfg(target_os = "macos")]
{
check_default_macos()
}
#[cfg(target_os = "linux")]
{
check_default_linux()
}
}
/// Attempt to set/prompt for Stirling PDF as default PDF handler
#[tauri::command]
pub fn set_as_default_pdf_handler() -> Result<String, String> {
add_log("⚙️ Attempting to set as default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
set_default_windows()
}
#[cfg(target_os = "macos")]
{
set_default_macos()
}
#[cfg(target_os = "linux")]
{
set_default_linux()
}
}
// ============================================================================
// Windows Implementation
// ============================================================================
#[cfg(target_os = "windows")]
fn check_default_windows() -> Result<bool, String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// Query the default handler for .pdf extension
let output = Command::new("cmd")
.args(["/C", "assoc .pdf"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let assoc = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows PDF association: {}", assoc.trim()));
// Get the ProgID for .pdf files
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
// Query what application handles this ProgID
let output = Command::new("cmd")
.args(["/C", &format!("ftype {}", prog_id)])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to query file type: {}", e))?;
let ftype = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows file type: {}", ftype.trim()));
// Check if it contains "Stirling" or our app name
let is_default = ftype.to_lowercase().contains("stirling");
Ok(is_default)
} else {
Ok(false)
}
}
#[cfg(target_os = "windows")]
fn set_default_windows() -> Result<String, String> {
// On Windows 10+, we need to open the Default Apps settings
// as programmatic setting requires a signed installer
Command::new("cmd")
.args(["/C", "start", "ms-settings:defaultapps"])
.spawn()
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
add_log("Opened Windows Default Apps settings".to_string());
Ok("opened_settings".to_string())
}
// ============================================================================
// macOS Implementation (using LaunchServices framework)
// ============================================================================
#[cfg(target_os = "macos")]
fn check_default_macos() -> Result<bool, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSCopyDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSCopyDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
) -> CFStringRef;
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
let pdf_uti = CFString::new("com.adobe.pdf");
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
if handler_ref.is_null() {
add_log("No default PDF handler found".to_string());
return Ok(false);
}
let handler = CFString::wrap_under_create_rule(handler_ref);
let handler_str = handler.to_string();
add_log(format!("macOS PDF handler: {}", handler_str));
// Check if it's our bundle identifier
let is_default = handler_str == "stirling.pdf.dev";
Ok(is_default)
}
}
#[cfg(target_os = "macos")]
fn set_default_macos() -> Result<String, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSSetDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
handler_bundle_id: CFStringRef,
) -> c_int; // OSStatus
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Set our app as the default handler for PDF files
let pdf_uti = CFString::new("com.adobe.pdf");
let our_bundle_id = CFString::new("stirling.pdf.dev");
let status = LSSetDefaultRoleHandlerForContentType(
pdf_uti.as_concrete_TypeRef(),
K_LS_ROLES_ALL,
our_bundle_id.as_concrete_TypeRef(),
);
if status == 0 {
add_log("Successfully triggered default app dialog".to_string());
Ok("set_successfully".to_string())
} else {
let error_msg = format!("LaunchServices returned status: {}", status);
add_log(error_msg.clone());
Err(error_msg)
}
}
}
// ============================================================================
// Linux Implementation
// ============================================================================
#[cfg(target_os = "linux")]
fn check_default_linux() -> Result<bool, String> {
// Use xdg-mime to check the default application for PDF files
let output = Command::new("xdg-mime")
.args(["query", "default", "application/pdf"])
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let handler = String::from_utf8_lossy(&output.stdout);
add_log(format!("Linux PDF handler: {}", handler.trim()));
// Check if it's our .desktop file
let is_default = handler.trim() == "stirling-pdf.desktop";
Ok(is_default)
}
#[cfg(target_os = "linux")]
fn set_default_linux() -> Result<String, String> {
// Use xdg-mime to set the default application for PDF files
let result = Command::new("xdg-mime")
.args(["default", "stirling-pdf.desktop", "application/pdf"])
.output()
.map_err(|e| format!("Failed to set default app: {}", e))?;
if result.status.success() {
add_log("Set as default PDF handler on Linux".to_string());
Ok("set_successfully".to_string())
} else {
let error = String::from_utf8_lossy(&result.stderr);
add_log(format!("Failed to set default: {}", error));
Err(format!("Failed to set as default: {}", error))
}
}
+5 -17
View File
@@ -14,23 +14,11 @@ pub fn add_opened_file(file_path: String) {
// Command to get opened file paths (if app was launched with files)
#[tauri::command]
pub async fn get_opened_files() -> Result<Vec<String>, String> {
let mut all_files: Vec<String> = Vec::new();
// Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour)
let args: Vec<String> = std::env::args().collect();
let pdf_files: Vec<String> = args.iter()
.skip(1)
.filter(|arg| std::path::Path::new(arg).exists())
.cloned()
.collect();
all_files.extend(pdf_files);
// Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files)
{
let opened_files = OPENED_FILES.lock().unwrap();
all_files.extend(opened_files.clone());
}
// Get all files from the OPENED_FILES store
// Command line args are processed in setup() callback and added to this store
// Additional files from second instances or events are also added here
let opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
Ok(all_files)
+2
View File
@@ -1,7 +1,9 @@
pub mod backend;
pub mod health;
pub mod files;
pub mod default_app;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
+38 -7
View File
@@ -3,7 +3,16 @@ use tauri::{RunEvent, WindowEvent, Emitter, Manager};
mod utils;
mod commands;
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
use commands::{
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
cleanup_backend,
add_opened_file,
is_default_pdf_handler,
set_as_default_pdf_handler,
};
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -23,9 +32,6 @@ pub fn run() {
// Store file for later retrieval (in case frontend isn't ready yet)
add_opened_file(arg.clone());
// Also emit event for immediate handling if frontend is ready
let _ = app.emit("file-opened", arg.clone());
// Bring the existing window to front
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
@@ -33,13 +39,34 @@ pub fn run() {
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
let _ = app.emit("files-changed", ());
}))
.setup(|_app| {
add_log("🚀 Tauri app setup started".to_string());
// Process command line arguments on first launch
let args: Vec<String> = std::env::args().collect();
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Initial file from command line: {}", arg));
add_opened_file(arg.clone());
}
}
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
.invoke_handler(tauri::generate_handler![
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
get_tauri_logs,
is_default_pdf_handler,
set_as_default_pdf_handler,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
@@ -58,6 +85,7 @@ pub fn run() {
#[cfg(target_os = "macos")]
RunEvent::Opened { urls } => {
add_log(format!("📂 Tauri file opened event: {:?}", urls));
let mut added_files = false;
for url in urls {
let url_str = url.as_str();
if url_str.starts_with("file://") {
@@ -65,11 +93,14 @@ pub fn run() {
if file_path.ends_with(".pdf") {
add_log(format!("📂 Processing opened PDF: {}", file_path));
add_opened_file(file_path.to_string());
// Use unified event name for consistency across platforms
let _ = app_handle.emit("file-opened", file_path.to_string());
added_files = true;
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
if added_files {
let _ = app_handle.emit("files-changed", ());
}
}
_ => {
// Only log unhandled events in debug mode to reduce noise
+5 -2
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
@@ -16,8 +17,10 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<HomePage />
<OnboardingTour />
<AppLayout>
<HomePage />
<OnboardingTour />
</AppLayout>
</AppProviders>
</Suspense>
);
@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
interface AppLayoutProps {
children: ReactNode;
}
/**
* App layout wrapper that handles banner rendering and viewport sizing
* Automatically adjusts child components to fit remaining space after banner
*/
export function AppLayout({ children }: AppLayoutProps) {
const { banner } = useBanner();
return (
<>
<style>{`
.h-screen,
.right-rail {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
</div>
</>
);
}
+29 -26
View File
@@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
@@ -50,22 +51,23 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<BannerProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
@@ -76,16 +78,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Stack, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
import { useSignature } from '@app/contexts/SignatureContext';
export interface AnnotationToolConfig {
enableDrawing?: boolean;
@@ -32,10 +33,34 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
undo,
redo
} = usePDFAnnotation();
const { historyApiRef } = useSignature();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const historyApiInstance = historyApiRef.current;
useEffect(() => {
if (!historyApiInstance) {
setHistoryAvailability({ canUndo: false, canRedo: false });
return;
}
const updateAvailability = () => {
setHistoryAvailability({
canUndo: historyApiInstance.canUndo?.() ?? false,
canRedo: historyApiInstance.canRedo?.() ?? false,
});
};
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
updateAvailability();
return () => {
unsubscribe?.();
};
}, [historyApiInstance]);
const handleSignatureDataChange = (data: string | null) => {
setSignatureData(data);
@@ -54,6 +79,8 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
<DrawingControls
onUndo={undo}
onRedo={redo}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
hasSignatureData={!!signatureData}
disabled={disabled}
@@ -86,4 +113,4 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
/>
</Stack>
);
};
};
@@ -1,5 +1,6 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ColorPickerProps {
isOpen: boolean;
@@ -14,13 +15,16 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
onClose,
selectedColor,
onColorChange,
title = "Choose Color"
title
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
return (
<Modal
opened={isOpen}
onClose={onClose}
title={title}
title={resolvedTitle}
size="sm"
centered
>
@@ -36,7 +40,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
/>
<Group justify="flex-end">
<Button onClick={onClose}>
Done
{t('common.done', 'Done')}
</Button>
</Group>
</Stack>
@@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
onClick={onClick}
/>
);
};
};
@@ -1,5 +1,6 @@
import React, { useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import React, { useEffect, useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
@@ -20,6 +21,7 @@ interface DrawingCanvasProps {
modalWidth?: number;
modalHeight?: number;
additionalButtons?: React.ReactNode;
initialSignatureData?: string;
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
@@ -34,12 +36,14 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
disabled = false,
width = 400,
height = 150,
initialSignatureData,
}) => {
const { t } = useTranslation();
const previewCanvasRef = 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 [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
@@ -55,6 +59,18 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
minDistance: 5,
velocityFilterWeight: 0.7,
});
// Restore saved signature data if it exists
if (savedSignatureData) {
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
};
img.src = savedSignatureData;
}
}
};
@@ -104,36 +120,35 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
return trimmedCanvas.toDataURL('image/png');
};
const renderPreview = (dataUrl: string) => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (canvas.width - scaledWidth) / 2;
const y = (canvas.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
};
img.src = dataUrl;
};
const closeModal = () => {
if (padRef.current && !padRef.current.isEmpty()) {
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
const untrimmedPng = canvas.toDataURL('image/png');
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
onSignatureDataChange(trimmedPng);
// Update preview canvas with proper aspect ratio
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);
}
}
};
img.src = trimmedPng;
renderPreview(trimmedPng);
if (onDrawingComplete) {
onDrawingComplete();
@@ -157,6 +172,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
}
setSavedSignatureData(null); // Clear saved signature
onSignatureDataChange(null);
};
@@ -173,67 +189,70 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
}
};
useEffect(() => {
updatePenColor(selectedColor);
}, [selectedColor]);
useEffect(() => {
updatePenSize(penSize);
}, [penSize]);
useEffect(() => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (!initialSignatureData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
return;
}
renderPreview(initialSignatureData);
}, [initialSignatureData]);
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<PrivateContent>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
</PrivateContent>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" 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>
<Group gap="lg" align="flex-end" wrap="wrap">
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.colorLabel', 'Colour')}
</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Stack>
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.penSizeLabel', 'Pen size')}
</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
@@ -242,12 +261,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
size="compact-sm"
style={{ width: '60px' }}
style={{ width: '80px' }}
/>
</div>
</div>
</Stack>
</Group>
<PrivateContent>
<canvas
@@ -263,7 +282,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
height: '25rem',
cursor: 'crosshair',
}}
/>
@@ -271,10 +290,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
Clear Canvas
{t('sign.canvas.clear', 'Clear canvas')}
</Button>
<Button onClick={closeModal}>
Done
{t('common.done', 'Done')}
</Button>
</div>
</Stack>
@@ -1,6 +1,7 @@
import React from 'react';
import { Group, Button } from '@mantine/core';
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
interface DrawingControlsProps {
onUndo?: () => void;
@@ -8,8 +9,11 @@ interface DrawingControlsProps {
onPlaceSignature?: () => void;
hasSignatureData?: boolean;
disabled?: boolean;
canUndo?: boolean;
canRedo?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
additionalControls?: React.ReactNode;
}
export const DrawingControls: React.FC<DrawingControlsProps> = ({
@@ -18,30 +22,48 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
onPlaceSignature,
hasSignatureData = false,
disabled = false,
canUndo = true,
canRedo = true,
showPlaceButton = true,
placeButtonText = "Update and Place"
placeButtonText = "Update and Place",
additionalControls,
}) => {
const { t } = useTranslation();
const undoDisabled = disabled || !canUndo;
const redoDisabled = disabled || !canRedo;
return (
<Group gap="sm">
{/* Undo/Redo Controls */}
<Button
variant="outline"
onClick={onUndo}
disabled={disabled}
flex={1}
>
{t('sign.undo', 'Undo')}
</Button>
<Button
variant="outline"
onClick={onRedo}
disabled={disabled}
flex={1}
>
{t('sign.redo', 'Redo')}
</Button>
<Group gap="xs" wrap="nowrap" align="center">
{onUndo && (
<Tooltip label={t('sign.undo', 'Undo')}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.undo', 'Undo')}
onClick={onUndo}
disabled={undoDisabled}
color={undoDisabled ? 'gray' : 'blue'}
>
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
</ActionIcon>
</Tooltip>
)}
{onRedo && (
<Tooltip label={t('sign.redo', 'Redo')}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.redo', 'Redo')}
onClick={onRedo}
disabled={redoDisabled}
color={redoDisabled ? 'gray' : 'blue'}
>
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
</ActionIcon>
</Tooltip>
)}
{additionalControls}
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
@@ -50,11 +72,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
flex={1}
ml="auto"
>
{placeButtonText}
</Button>
)}
</Group>
);
};
};
@@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInput, setColorInput] = useState(textColor);
// Sync font size input with prop changes
useEffect(() => {
setFontSizeInput(fontSize.toString());
}, [fontSize]);
// Sync color input with prop changes
useEffect(() => {
setColorInput(textColor);
}, [textColor]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
@@ -50,10 +56,15 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
// Validate hex color
const isValidHexColor = (color: string): boolean => {
return /^#[0-9A-Fa-f]{6}$/.test(color);
};
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer Name')}
label={label || t('sign.text.name', 'Signer name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
value={text}
onChange={(e) => onTextChange(e.target.value)}
@@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Font Selection */}
<Select
label="Font"
label={t('sign.text.fontLabel', 'Font')}
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
data={fontOptions}
@@ -88,8 +99,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
label={t('sign.text.fontSizeLabel', 'Font size')}
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
@@ -135,14 +146,29 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
label={t('sign.text.colorLabel', 'Text colour')}
value={colorInput}
placeholder="#000000"
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
onChange={(e) => {
const value = e.currentTarget.value;
setColorInput(value);
// Update color if valid hex
if (isValidHexColor(value)) {
onTextColorChange(value);
}
}}
onBlur={() => {
// Revert to valid color on blur if invalid
if (!isValidHexColor(colorInput)) {
setColorInput(textColor);
}
}}
style={{ width: '100%' }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{
width: 24,
height: 24,
@@ -169,4 +195,4 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
)}
</Stack>
);
};
};
@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useTranslation } from 'react-i18next';
@@ -10,6 +10,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
import '@app/components/onboarding/OnboardingTour.css';
import i18n from "@app/i18n";
// Enum case order defines order steps will appear
enum TourStep {
@@ -120,7 +121,7 @@ export default function OnboardingTour() {
} = useAdminTourOrchestration();
// Define steps as object keyed by enum - TypeScript ensures all keys are present
const stepsConfig: Record<TourStep, StepType> = {
const stepsConfig: Record<TourStep, StepType> = useMemo(() => ({
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
@@ -248,10 +249,10 @@ export default function OnboardingTour() {
position: 'right',
padding: 10,
},
};
}), [t]);
// Define admin tour steps
const adminStepsConfig: Record<AdminTourStep, StepType> = {
const adminStepsConfig: Record<AdminTourStep, StepType> = useMemo(() => ({
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
@@ -363,7 +364,7 @@ export default function OnboardingTour() {
removeAllGlows();
},
},
};
}), [t]);
// Select steps based on tour type
const steps = tourType === 'admin'
@@ -416,7 +417,7 @@ export default function OnboardingTour() {
}}
/>
<TourProvider
key={tourType}
key={`${tourType}-${i18n.language}`}
steps={steps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
onClickClose={handleCloseTour}
@@ -0,0 +1,72 @@
import React from 'react';
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
interface InfoBannerProps {
icon: string;
message: string;
buttonText: string;
buttonIcon?: string;
onButtonClick: () => void;
onDismiss: () => void;
loading?: boolean;
show?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
loading = false,
show = true,
}) => {
if (!show) {
return null;
}
return (
<Paper
p="sm"
radius={0}
style={{
background: 'var(--mantine-color-blue-0)',
borderBottom: '1px solid var(--mantine-color-blue-2)',
position: 'relative',
}}
>
<Group gap="sm" align="center" wrap="nowrap">
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{message}
</Text>
<Button
variant="light"
color="blue"
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
style={{ flexShrink: 0 }}
>
{buttonText}
</Button>
</Group>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onDismiss}
aria-label="Dismiss"
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
</Paper>
);
};
@@ -272,8 +272,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
<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 enabledLanguages = [
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR',
'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB',
'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID',
'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ'
];
const isDisabled = !enabledLanguages.includes(option.value);
return (
@@ -0,0 +1,415 @@
import React, { useState, useEffect } from 'react';
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadIcon from '@mui/icons-material/Download';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
interface UpdateModalProps {
opened: boolean;
onClose: () => void;
currentVersion: string;
updateSummary: UpdateSummary;
machineInfo: MachineInfo;
}
const UpdateModal: React.FC<UpdateModalProps> = ({
opened,
onClose,
currentVersion,
updateSummary,
machineInfo,
}) => {
const { t } = useTranslation();
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(new Set([0]));
useEffect(() => {
if (opened) {
setLoading(true);
setExpandedVersions(new Set([0]));
updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => {
setFullUpdateInfo(info);
setLoading(false);
});
}
}, [opened, currentVersion, machineInfo]);
const toggleVersion = (index: number) => {
setExpandedVersions((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const getPriorityColor = (priority: string): string => {
switch (priority?.toLowerCase()) {
case 'urgent':
return 'red';
case 'normal':
return 'blue';
case 'minor':
return 'cyan';
case 'low':
return 'gray';
default:
return 'gray';
}
};
const getPriorityLabel = (priority: string): string => {
const key = priority?.toLowerCase();
return t(`update.priority.${key}`, priority || 'Normal');
};
const downloadUrl = updateService.getDownloadUrl(machineInfo);
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text fw={600} size="lg">
{t('update.modalTitle', 'Update Available')}
</Text>
}
centered
size="xl"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {
maxHeight: '75vh',
overflowY: 'auto',
},
}}
>
<Stack gap="lg" pt="md">
{/* Version Summary Section */}
<Box>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
<Stack gap={4} style={{ flex: 1 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.current', 'Current Version')}
</Text>
<Text fw={600} size="xl">
{currentVersion}
</Text>
</Stack>
<Stack gap={4} style={{ flex: 1 }} ta="center">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.priorityLabel', 'Priority')}
</Text>
<Badge
color={getPriorityColor(updateSummary.max_priority)}
size="lg"
variant="filled"
style={{ alignSelf: 'center' }}
>
{getPriorityLabel(updateSummary.max_priority)}
</Badge>
</Stack>
<Stack gap={4} style={{ flex: 1 }} ta="right">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.latest', 'Latest Version')}
</Text>
<Text fw={600} size="xl" c="blue">
{updateSummary.latest_version}
</Text>
</Stack>
</Group>
{updateSummary.latest_stable_version && (
<Box
style={{
background: 'var(--mantine-color-green-0)',
padding: '10px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-green-2)',
}}
>
<Group gap="xs" justify="center">
<Text size="sm" fw={500}>
{t('update.latestStable', 'Latest Stable')}:
</Text>
<Text size="sm" fw={600} c="green">
{updateSummary.latest_stable_version}
</Text>
</Group>
</Box>
)}
</Box>
{/* Recommended action */}
{updateSummary.recommended_action && (
<Box
style={{
background: 'var(--mantine-color-blue-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-blue-outline)',
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<InfoOutlinedIcon style={{ fontSize: 18, color: 'var(--mantine-color-blue-filled)', marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.recommendedAction', 'Recommended Action')}
</Text>
<Text size="sm">
{updateSummary.recommended_action}
</Text>
</Box>
</Group>
</Box>
)}
{/* Breaking changes warning */}
{updateSummary.any_breaking && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-orange-outline)',
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<WarningAmberIcon style={{ fontSize: 18, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.breakingChangesDetected', 'Breaking Changes Detected')}
</Text>
<Text size="sm">
{t(
'update.breakingChangesMessage',
'Some versions contain breaking changes. Please review the migration guides below before updating.'
)}
</Text>
</Box>
</Group>
</Box>
)}
{/* Migration guides */}
{updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && (
<>
<Divider />
<Stack gap="xs">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.migrationGuides', 'Migration Guides')}
</Text>
{updateSummary.migration_guides.map((guide, idx) => (
<Box
key={idx}
style={{
border: '1px solid var(--mantine-color-gray-3)',
padding: '12px 16px',
borderRadius: '8px',
background: 'var(--mantine-color-gray-0)',
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t('update.version', 'Version')} {guide.version}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{guide.notes}
</Text>
</Box>
<Button
component="a"
href={guide.url}
target="_blank"
variant="light"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.viewGuide', 'View Guide')}
</Button>
</Group>
</Box>
))}
</Stack>
</>
)}
{/* Version details */}
<Divider />
{loading ? (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('update.loadingDetailedInfo', 'Loading detailed information...')}
</Text>
</Stack>
</Center>
) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.availableUpdates', 'Available Updates')}
</Text>
<Badge variant="light" color="gray">
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'}
</Badge>
</Group>
<Stack gap="xs">
{fullUpdateInfo.new_versions.map((version, index) => {
const isExpanded = expandedVersions.has(index);
return (
<Box
key={index}
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<Group
justify="space-between"
align="center"
p="md"
style={{
cursor: 'pointer',
background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent',
transition: 'background 0.15s ease',
}}
onClick={() => toggleVersion(index)}
>
<Group gap="md" style={{ flex: 1 }}>
<Box>
<Text fw={600} size="sm" c="dimmed" mb={2}>
{t('update.version', 'Version')}
</Text>
<Text fw={700} size="lg">
{version.version}
</Text>
</Box>
<Badge color={getPriorityColor(version.priority)} size="md">
{getPriorityLabel(version.priority)}
</Badge>
</Group>
<Group gap="xs">
<Button
component="a"
href={`https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v${version.version}`}
target="_blank"
variant="light"
size="xs"
onClick={(e) => e.stopPropagation()}
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.releaseNotes', 'Release Notes')}
</Button>
{isExpanded ? (
<ExpandLessIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
) : (
<ExpandMoreIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
)}
</Group>
</Group>
<Collapse in={isExpanded}>
<Box p="md" pt={0} style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}>
<Stack gap="md" mt="md">
<Box>
<Text fw={600} size="sm" mb={6}>
{version.announcement.title}
</Text>
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
{version.announcement.message}
</Text>
</Box>
{version.compatibility.breaking_changes && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px',
borderRadius: '6px',
border: '1px solid var(--mantine-color-orange-outline)',
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
<WarningAmberIcon style={{ fontSize: 16, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<Text size="xs" fw={600} tt="uppercase">
{t('update.breakingChanges', 'Breaking Changes')}
</Text>
</Group>
<Text size="sm" mb="xs">
{version.compatibility.breaking_description ||
t('update.breakingChangesDefault', 'This version contains breaking changes.')}
</Text>
{version.compatibility.migration_guide_url && (
<Button
component="a"
href={version.compatibility.migration_guide_url}
target="_blank"
variant="light"
color="orange"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.migrationGuide', 'Migration Guide')}
</Button>
)}
</Box>
)}
</Stack>
</Box>
</Collapse>
</Box>
);
})}
</Stack>
</Stack>
) : null}
{/* Action buttons */}
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('update.close', 'Close')}
</Button>
<Button
variant="light"
component="a"
href="https://github.com/Stirling-Tools/Stirling-PDF/releases"
target="_blank"
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{t('update.viewAllReleases', 'View All Releases')}
</Button>
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
target="_blank"
color="green"
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
>
{t('update.downloadLatest', 'Download Latest')}
</Button>
)}
</Group>
</Stack>
</Modal>
);
};
export default UpdateModal;
@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { ToolPanelMode } from '@app/constants/toolPanel';
import LocalIcon from '@app/components/shared/LocalIcon';
import { updateService, UpdateSummary } from '@app/services/updateService';
import UpdateModal from '@app/components/shared/UpdateModal';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
@@ -22,12 +24,44 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
// Check localStorage on mount
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
});
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
// Check for updates on mount
useEffect(() => {
if (config?.appVersion && config?.machineType) {
checkForUpdate();
}
}, [config?.appVersion, config?.machineType]);
const checkForUpdate = async () => {
if (!config?.appVersion || !config?.machineType) {
return;
}
setCheckingUpdate(true);
const machineInfo = {
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? 'NORMAL',
};
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
if (summary) {
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
if (isNewerVersion) {
setUpdateSummary(summary);
}
}
setCheckingUpdate(false);
};
// Check if login is disabled
const loginDisabled = !config?.enableLogin;
@@ -170,6 +204,108 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Tooltip>
</Stack>
</Paper>
{/* Update Check Section */}
{config?.appVersion && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="sm">
{t('settings.general.updates.title', 'Software Updates')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.updates.description', 'Check for updates and view version information')}
</Text>
</div>
{updateSummary && (
<Badge
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
variant="filled"
>
{updateSummary.max_priority === 'urgent'
? t('update.urgentUpdateAvailable', 'Urgent Update')
: t('update.updateAvailable', 'Update Available')}
</Badge>
)}
</Group>
</div>
<Group justify="space-between" align="center">
<div>
<Text size="sm" c="dimmed">
{t('settings.general.updates.currentVersion', 'Current Version')}:{' '}
<Text component="span" fw={500}>
{config.appVersion}
</Text>
</Text>
{updateSummary && (
<Text size="sm" c="dimmed" mt={4}>
{t('settings.general.updates.latestVersion', 'Latest Version')}:{' '}
<Text component="span" fw={500} c="blue">
{updateSummary.latest_version}
</Text>
</Text>
)}
</div>
<Group gap="sm">
<Button
size="sm"
variant="default"
onClick={checkForUpdate}
loading={checkingUpdate}
leftSection={<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.checkForUpdates', 'Check for Updates')}
</Button>
{updateSummary && (
<Button
size="sm"
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
onClick={() => setUpdateModalOpened(true)}
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.viewDetails', 'View Details')}
</Button>
)}
</Group>
</Group>
{updateSummary?.any_breaking && (
<Alert
color="orange"
title={t('update.breakingChangesDetected', 'Breaking Changes Detected')}
styles={{
title: { fontWeight: 600 }
}}
>
<Text size="sm">
{t(
'update.breakingChangesMessage',
'Some versions contain breaking changes. Please review the migration guides before updating.'
)}
</Text>
</Alert>
)}
</Stack>
</Paper>
)}
{/* Update Modal */}
{updateSummary && config?.appVersion && config?.machineType && (
<UpdateModal
opened={updateModalOpened}
onClose={() => setUpdateModalOpened(false)}
currentVersion={config.appVersion}
updateSummary={updateSummary}
machineInfo={{
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? 'NORMAL',
}}
/>
)}
</Stack>
);
};
@@ -0,0 +1,313 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActionIcon,
Badge,
Button,
Flex,
Group,
NumberInput,
Paper,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode, createBookmarkNode } from '@app/utils/editTableOfContents';
interface BookmarkEditorProps {
bookmarks: BookmarkNode[];
onChange: (bookmarks: BookmarkNode[]) => void;
disabled?: boolean;
}
const updateTree = (
nodes: BookmarkNode[],
targetId: string,
updater: (bookmark: BookmarkNode) => BookmarkNode,
): BookmarkNode[] => {
return nodes.map(node => {
if (node.id === targetId) {
return updater(node);
}
if (node.children.length === 0) {
return node;
}
const updatedChildren = updateTree(node.children, targetId, updater);
if (updatedChildren !== node.children) {
return { ...node, children: updatedChildren };
}
return node;
});
};
const removeFromTree = (nodes: BookmarkNode[], targetId: string): BookmarkNode[] => {
return nodes
.filter(node => node.id !== targetId)
.map(node => ({
...node,
children: removeFromTree(node.children, targetId),
}));
};
const addChildToTree = (
nodes: BookmarkNode[],
parentId: string,
child: BookmarkNode,
): { nodes: BookmarkNode[]; added: boolean } => {
let added = false;
const next = nodes.map(node => {
if (node.id === parentId) {
added = true;
return { ...node, expanded: true, children: [...node.children, child] };
}
if (node.children.length === 0) {
return node;
}
const result = addChildToTree(node.children, parentId, child);
if (result.added) {
added = true;
return { ...node, children: result.nodes };
}
return node;
});
return { nodes: added ? next : nodes, added };
};
const addSiblingInTree = (
nodes: BookmarkNode[],
targetId: string,
sibling: BookmarkNode,
): { nodes: BookmarkNode[]; added: boolean } => {
let added = false;
const result: BookmarkNode[] = [];
nodes.forEach(node => {
let currentNode = node;
if (!added && node.children.length > 0) {
const childResult = addSiblingInTree(node.children, targetId, sibling);
if (childResult.added) {
added = true;
currentNode = { ...node, children: childResult.nodes };
}
}
result.push(currentNode);
if (!added && node.id === targetId) {
result.push(sibling);
added = true;
}
});
return { nodes: added ? result : nodes, added };
};
export default function BookmarkEditor({ bookmarks, onChange, disabled }: BookmarkEditorProps) {
const { t } = useTranslation();
const handleAddTopLevel = () => {
const newBookmark = createBookmarkNode({ title: t('editTableOfContents.editor.defaultTitle', 'New bookmark') });
onChange([...bookmarks, newBookmark]);
};
const handleTitleChange = (id: string, value: string) => {
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, title: value })));
};
const handlePageChange = (id: string, value: number | string) => {
const page = typeof value === 'number' ? value : parseInt(value, 10);
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, pageNumber: Number.isFinite(page) && page > 0 ? page : 1 })));
};
const handleToggle = (id: string) => {
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, expanded: !bookmark.expanded })));
};
const handleRemove = (id: string) => {
const confirmation = t(
'editTableOfContents.editor.confirmRemove',
'Remove this bookmark and all of its children?'
);
if (window.confirm(confirmation)) {
onChange(removeFromTree(bookmarks, id));
}
};
const handleAddChild = (parentId: string) => {
const child = createBookmarkNode({ title: t('editTableOfContents.editor.defaultChildTitle', 'Child bookmark') });
const { nodes, added } = addChildToTree(bookmarks, parentId, child);
onChange(added ? nodes : [...bookmarks, child]);
};
const handleAddSibling = (targetId: string) => {
const sibling = createBookmarkNode({ title: t('editTableOfContents.editor.defaultSiblingTitle', 'New bookmark') });
const { nodes, added } = addSiblingInTree(bookmarks, targetId, sibling);
onChange(added ? nodes : [...bookmarks, sibling]);
};
const renderBookmark = (bookmark: BookmarkNode, level = 0) => {
const hasChildren = bookmark.children.length > 0;
const chevronIcon = bookmark.expanded ? 'expand-more-rounded' : 'chevron-right-rounded';
return (
<Paper
key={bookmark.id}
radius="md"
withBorder
p="md"
style={{
borderColor: 'var(--border-default)',
background: level === 0 ? 'var(--bg-surface)' : 'var(--bg-muted)',
}}
>
<Stack gap="sm">
<Flex align="flex-start" justify="space-between" gap="md">
<Group gap="sm" align="flex-start">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => hasChildren && handleToggle(bookmark.id)}
disabled={disabled || !hasChildren}
aria-label={t('editTableOfContents.editor.actions.toggle', 'Toggle children')}
style={{ marginTop: 4 }}
>
<LocalIcon icon={chevronIcon} />
</ActionIcon>
<Stack gap={2}>
<Group gap="xs" align="center">
<Text fw={600}>{bookmark.title || t('editTableOfContents.editor.untitled', 'Untitled bookmark')}</Text>
{level > 0 && (
<Badge size="xs" variant="light" color="blue">
{t('editTableOfContents.editor.childBadge', 'Child')}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t('editTableOfContents.editor.pagePreview', { page: bookmark.pageNumber })}
</Text>
</Stack>
</Group>
<Group gap="xs">
<Tooltip label={t('editTableOfContents.editor.actions.addChild', 'Add child bookmark')}>
<ActionIcon
variant="subtle"
color="green"
onClick={() => handleAddChild(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="subdirectory-arrow-right-rounded" />
</ActionIcon>
</Tooltip>
<Tooltip label={t('editTableOfContents.editor.actions.addSibling', 'Add sibling bookmark')}>
<ActionIcon
variant="subtle"
color="blue"
onClick={() => handleAddSibling(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="add-rounded" />
</ActionIcon>
</Tooltip>
<Tooltip label={t('editTableOfContents.editor.actions.remove', 'Remove bookmark')}>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleRemove(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="delete-rounded" />
</ActionIcon>
</Tooltip>
</Group>
</Flex>
{bookmark.expanded && (
<Stack gap="sm">
<TextInput
size="sm"
label={t('editTableOfContents.editor.field.title', 'Bookmark title')}
value={bookmark.title}
onChange={event => handleTitleChange(bookmark.id, event.currentTarget.value)}
disabled={disabled}
/>
<NumberInput
size="sm"
label={t('editTableOfContents.editor.field.page', 'Target page number')}
min={1}
clampBehavior="strict"
value={bookmark.pageNumber}
onChange={value => handlePageChange(bookmark.id, value ?? 1)}
disabled={disabled}
/>
</Stack>
)}
{bookmark.expanded && hasChildren && (
<Stack gap="sm" pl="lg" style={{ borderLeft: '1px solid var(--border-default)' }}>
{bookmark.children.map(child => (
<Fragment key={child.id}>{renderBookmark(child, level + 1)}</Fragment>
))}
</Stack>
)}
</Stack>
</Paper>
);
};
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.editor.description', 'Add, nest, and reorder bookmarks to craft your PDF outline.')}
</Text>
</div>
<Button
variant="default"
color="blue"
leftSection={<LocalIcon icon="bookmark-add-rounded" />}
onClick={handleAddTopLevel}
disabled={disabled}
>
{t('editTableOfContents.editor.addTopLevel', 'Add top-level bookmark')}
</Button>
</Group>
{bookmarks.length === 0 ? (
<Paper withBorder radius="md" ta="center" py="xl">
<Stack gap="xs" align="center" px="lg">
<LocalIcon icon="bookmark-add-rounded" style={{ fontSize: '2.25rem' }} />
<Text fw={600}>{t('editTableOfContents.editor.empty.title', 'No bookmarks yet')}</Text>
<Text size="sm" c="dimmed" maw={420}>
{t('editTableOfContents.editor.empty.description', 'Import existing bookmarks or start by adding your first entry.')}
</Text>
<Button
variant="subtle"
color="blue"
leftSection={<LocalIcon icon="add-rounded" />}
onClick={handleAddTopLevel}
disabled={disabled}
>
{t('editTableOfContents.editor.empty.action', 'Add first bookmark')}
</Button>
</Stack>
</Paper>
) : (
<Stack gap="sm">
{bookmarks.map(bookmark => renderBookmark(bookmark))}
</Stack>
)}
</Stack>
);
}
@@ -0,0 +1,187 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Divider,
FileButton,
Stack,
Switch,
Text,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode } from '@app/utils/editTableOfContents';
interface EditTableOfContentsSettingsProps {
bookmarks: BookmarkNode[];
replaceExisting: boolean;
onReplaceExistingChange: (value: boolean) => void;
onSelectFiles: () => void;
onLoadFromPdf: () => void;
onImportJson: (file: File) => void;
onImportClipboard: () => void;
onExportJson: () => void;
onExportClipboard: () => void;
isLoading: boolean;
loadError?: string | null;
canReadClipboard: boolean;
canWriteClipboard: boolean;
disabled?: boolean;
selectedFileName?: string;
}
export default function EditTableOfContentsSettings({
bookmarks,
replaceExisting,
onReplaceExistingChange,
onSelectFiles,
onLoadFromPdf,
onImportJson,
onImportClipboard,
onExportJson,
onExportClipboard,
isLoading,
loadError,
canReadClipboard,
canWriteClipboard,
disabled,
selectedFileName,
}: EditTableOfContentsSettingsProps) {
const { t } = useTranslation();
const infoLines = useMemo(() => ([
t('editTableOfContents.info.line1', 'Each bookmark needs a descriptive title and the page it should open.'),
t('editTableOfContents.info.line2', 'Use child bookmarks to build a hierarchy for chapters, sections, or subsections.'),
t('editTableOfContents.info.line3', 'Import bookmarks from the selected PDF or from a JSON file to save time.'),
]), [t]);
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>{t('editTableOfContents.actions.source', 'Load bookmarks')}</Text>
<Text size="xs" c="dimmed">
{selectedFileName
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
: t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.')}
</Text>
</Stack>
<Stack gap="sm">
<Button
variant="light"
leftSection={<LocalIcon icon="folder-rounded" />}
onClick={onSelectFiles}
fullWidth
>
{selectedFileName
? t('editTableOfContents.workbench.changeFile', 'Change PDF')
: t('editTableOfContents.workbench.selectFile', 'Select PDF')}
</Button>
<Tooltip label={!selectedFileName ? t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.') : ''} disabled={Boolean(selectedFileName)}>
<Button
variant="default"
leftSection={<LocalIcon icon="picture-as-pdf-rounded" />}
onClick={onLoadFromPdf}
loading={isLoading}
disabled={disabled || !selectedFileName}
fullWidth
>
{t('editTableOfContents.actions.loadFromPdf', 'Load from PDF')}
</Button>
</Tooltip>
<FileButton
onChange={file => file && onImportJson(file)}
accept="application/json"
disabled={disabled}
>
{(props) => (
<Button
{...props}
variant="default"
leftSection={<LocalIcon icon="upload-rounded" />}
disabled={disabled}
fullWidth
>
{t('editTableOfContents.actions.importJson', 'Import JSON')}
</Button>
)}
</FileButton>
<Tooltip
label={canReadClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
disabled={canReadClipboard}
>
<Button
variant="default"
leftSection={<LocalIcon icon="content-paste-rounded" />}
onClick={onImportClipboard}
disabled={disabled || !canReadClipboard}
fullWidth
>
{t('editTableOfContents.actions.importClipboard', 'Paste from clipboard')}
</Button>
</Tooltip>
</Stack>
{loadError && (
<Alert color="red" radius="md" icon={<LocalIcon icon="error-outline-rounded" />}>
{loadError}
</Alert>
)}
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('editTableOfContents.actions.export', 'Export bookmarks')}</Text>
</Stack>
<Stack gap="sm">
<Button
variant="default"
leftSection={<LocalIcon icon="download-rounded" />}
onClick={onExportJson}
disabled={disabled || bookmarks.length === 0}
fullWidth
>
{t('editTableOfContents.actions.exportJson', 'Download JSON')}
</Button>
<Tooltip
label={canWriteClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
disabled={canWriteClipboard}
>
<Button
variant="default"
leftSection={<LocalIcon icon="content-copy-rounded" />}
onClick={onExportClipboard}
disabled={disabled || bookmarks.length === 0 || !canWriteClipboard}
fullWidth
>
{t('editTableOfContents.actions.exportClipboard', 'Copy to clipboard')}
</Button>
</Tooltip>
</Stack>
<Divider />
<Switch
checked={replaceExisting}
onChange={(event) => onReplaceExistingChange(event.currentTarget.checked)}
label={t('editTableOfContents.settings.replaceExisting', 'Replace existing bookmarks')}
description={t('editTableOfContents.settings.replaceExistingHint', 'When disabled, the new outline is appended after the current bookmarks.')}
disabled={disabled}
/>
<Stack gap="xs">
{infoLines.map((line, index) => (
<Text key={index} size="sm" c="dimmed">
{line}
</Text>
))}
</Stack>
</Stack>
);
}
@@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Button,
Card,
Divider,
Group,
Stack,
Text,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode } from '@app/utils/editTableOfContents';
import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
export interface EditTableOfContentsWorkbenchViewData {
bookmarks: BookmarkNode[];
selectedFileName?: string;
disabled: boolean;
files: File[];
thumbnails: (string | undefined)[];
downloadUrl: string | null;
downloadFilename: string | null;
errorMessage: string | null;
isGeneratingThumbnails: boolean;
isExecuteDisabled: boolean;
isExecuting: boolean;
onClearError: () => void;
onBookmarksChange: (bookmarks: BookmarkNode[]) => void;
onExecute: () => void;
onUndo: () => void;
onFileClick: (file: File) => void;
}
interface EditTableOfContentsWorkbenchViewProps {
data: EditTableOfContentsWorkbenchViewData | null;
}
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
const { t } = useTranslation();
if (!data) {
return (
<Box p="xl">
<Card withBorder radius="md">
<Stack gap="sm">
<Text fw={600}>{t('editTableOfContents.workbench.empty.title', 'Open the tool to start editing')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.workbench.empty.description', 'Select the Edit Table of Contents tool to load its workspace.')}
</Text>
</Stack>
</Card>
</Box>
);
}
const {
bookmarks,
selectedFileName,
disabled,
files,
thumbnails,
downloadUrl,
downloadFilename,
errorMessage,
isGeneratingThumbnails,
isExecuteDisabled,
isExecuting,
onClearError,
onBookmarksChange,
onExecute,
onUndo,
onFileClick,
} = data;
const previewFiles = useMemo(
() =>
files?.map((file, index) => ({
file,
thumbnail: thumbnails[index],
})) ?? [],
[files, thumbnails]
);
const showResults = Boolean(
previewFiles.length > 0 || downloadUrl || errorMessage
);
return (
<Box
p="lg"
style={{
width: '100%',
height: '100%',
overflowY: 'auto',
background: 'var(--bg-raised)',
}}
>
<Stack gap="xl" maw={1200} mx="auto">
<Stack gap={4}>
<Text size="xl" fw={700}>
{t('home.editTableOfContents.title', 'Edit Table of Contents')}
</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.workbench.subtitle', 'Import bookmarks, build hierarchies, and apply the outline without cramped side panels.')}
</Text>
</Stack>
<Card
withBorder
radius="md"
p="xl"
style={{
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
boxShadow: 'var(--shadow-md)',
}}
>
<Stack gap="md">
<Stack gap={2}>
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
<Text size="sm" c="dimmed">
{selectedFileName
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
: t('editTableOfContents.workbench.filePrompt', 'Select a PDF from your library or upload a new one to begin.')}
</Text>
</Stack>
<BookmarkEditor bookmarks={bookmarks} onChange={onBookmarksChange} disabled={disabled} />
<Divider />
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="menu-book-rounded" />}
color="blue"
onClick={onExecute}
disabled={isExecuteDisabled}
loading={isExecuting}
>
{t('editTableOfContents.submit', 'Apply table of contents')}
</Button>
</Group>
</Stack>
</Card>
{showResults && (
<Card
withBorder
radius="md"
p="xl"
style={{
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
boxShadow: 'var(--shadow-md)',
}}
>
<Stack gap="md">
<Stack gap={4}>
<Text fw={600}>{t('editTableOfContents.results.title', 'Updated PDF with bookmarks')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.results.subtitle', 'Download the processed file or undo the operation below.')}
</Text>
</Stack>
<ErrorNotification error={errorMessage} onClose={onClearError} />
{previewFiles.length > 0 && (
<ResultsPreview
files={previewFiles}
onFileClick={onFileClick}
isGeneratingThumbnails={isGeneratingThumbnails}
/>
)}
<Group justify="flex-end" gap="sm">
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
download={downloadFilename ?? undefined}
leftSection={<LocalIcon icon='download-rounded' />}
>
{t('download', 'Download')}
</Button>
)}
<Button
variant="outline"
leftSection={<LocalIcon icon="rotate-left" />}
onClick={onUndo}
disabled={isExecuting}
>
{t('undo', 'Undo')}
</Button>
</Group>
</Stack>
</Card>
)}
</Stack>
</Box>
);
};
export default EditTableOfContentsWorkbenchView;
@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from "react-i18next";
import { Stack, Button, Text, Alert, Tabs, SegmentedControl } from '@mantine/core';
import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core';
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection";
import { useSignature } from "@app/contexts/SignatureContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from '@app/constants/signConstants';
// Import the new reusable components
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
@@ -11,6 +13,18 @@ import { DrawingControls } from "@app/components/annotation/shared/DrawingContro
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { LocalIcon } from "@app/components/shared/LocalIcon";
type SignatureDrafts = {
canvas?: string;
image?: string;
text?: {
signerName: string;
fontSize: number;
fontFamily: string;
textColor: string;
};
};
interface SignSettingsProps {
parameters: SignParameters;
@@ -31,23 +45,64 @@ const SignSettings = ({
disabled = false,
onActivateSignaturePlacement,
onDeactivateSignature,
onUpdateDrawSettings,
onUndo,
onRedo,
onSave
}: SignSettingsProps) => {
const { t } = useTranslation();
const { isPlacementMode } = useSignature();
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
const { activeFileIndex } = useViewer();
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const historyApiInstance = historyApiRef.current;
// State for drawing
const [selectedColor, setSelectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move');
const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false);
// State for different signature types
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
const [imageSignatureData, setImageSignatureData] = useState<string | null>(null);
const [canvasSignatureData, setCanvasSignatureData] = useState<string | undefined>();
const [imageSignatureData, setImageSignatureData] = useState<string | undefined>();
const [signatureDrafts, setSignatureDrafts] = useState<SignatureDrafts>({});
const lastSyncedTextDraft = useRef<SignatureDrafts['text'] | null>(null);
const lastAppliedPlacementKey = useRef<string | null>(null);
const previousFileIndexRef = useRef(activeFileIndex);
useEffect(() => {
if (!disabled) {
onUpdateDrawSettings?.(selectedColor, penSize);
}
}, [selectedColor, penSize, disabled, onUpdateDrawSettings]);
useEffect(() => {
if (signaturesApplied) {
setPlacementManuallyPaused(false);
}
}, [signaturesApplied]);
useEffect(() => {
if (!historyApiInstance) {
setHistoryAvailability({ canUndo: false, canRedo: false });
return;
}
const updateAvailability = () => {
setHistoryAvailability({
canUndo: historyApiInstance.canUndo?.() ?? false,
canRedo: historyApiInstance.canRedo?.() ?? false,
});
};
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
updateAvailability();
return () => {
unsubscribe?.();
};
}, [historyApiInstance]);
// Handle image upload
const handleImageChange = async (file: File | null) => {
@@ -66,64 +121,209 @@ const SignSettings = ({
reader.readAsDataURL(file);
});
// Clear any existing canvas signatures when uploading image
setCanvasSignatureData(null);
setImageSignatureData(result);
} catch (error) {
console.error('Error reading file:', error);
}
} else if (!file) {
setImageSignatureData(null);
if (onDeactivateSignature) {
onDeactivateSignature();
}
setImageSignatureData(undefined);
onDeactivateSignature?.();
}
};
// Handle signature data changes
const handleCanvasSignatureChange = (data: string | null) => {
const nextValue = data ?? undefined;
setCanvasSignatureData(prev => {
if (prev === data) return prev; // Prevent unnecessary updates
return data;
if (prev === nextValue) {
return prev;
}
return nextValue;
});
if (data) {
// Clear image data when canvas is used
setImageSignatureData(null);
}
};
// Handle signature mode deactivation when switching types
useEffect(() => {
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
onDeactivateSignature();
}
}, [parameters.signatureType]);
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
const hasTextSignature = useMemo(
() => Boolean(parameters.signerName && parameters.signerName.trim() !== ''),
[parameters.signerName]
);
const hasAnySignature = hasCanvasSignature || hasImageSignature || hasTextSignature;
const isCurrentTypeReady = useMemo(() => {
switch (parameters.signatureType) {
case 'canvas':
return hasCanvasSignature;
case 'image':
return hasImageSignature;
case 'text':
return hasTextSignature;
default:
return false;
}
}, [parameters.signatureType, hasCanvasSignature, hasImageSignature, hasTextSignature]);
const placementSignatureKey = useMemo(() => {
if (!isCurrentTypeReady) {
return null;
}
switch (parameters.signatureType) {
case 'canvas':
return canvasSignatureData ?? null;
case 'image':
return imageSignatureData ?? null;
case 'text':
return JSON.stringify({
signerName: (parameters.signerName ?? '').trim(),
fontSize: parameters.fontSize ?? 16,
fontFamily: parameters.fontFamily ?? 'Helvetica',
textColor: parameters.textColor ?? '#000000',
});
default:
return null;
}
}, [
isCurrentTypeReady,
parameters.signatureType,
canvasSignatureData,
imageSignatureData,
parameters.signerName,
parameters.fontSize,
parameters.fontFamily,
parameters.textColor,
]);
const shouldEnablePlacement = useMemo(() => {
if (disabled) return false;
return isCurrentTypeReady;
}, [disabled, isCurrentTypeReady]);
const shouldAutoActivate = shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied;
// Handle text signature activation (including fontSize and fontFamily changes)
useEffect(() => {
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
if (onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
setSignatureDrafts(prev => {
if (canvasSignatureData) {
if (prev.canvas === canvasSignatureData) {
return prev;
}
return { ...prev, canvas: canvasSignatureData };
}
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
if (onDeactivateSignature) {
setInteractionMode('move');
onDeactivateSignature();
if (prev.canvas !== undefined) {
const next = { ...prev };
delete next.canvas;
return next;
}
}
}, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]);
// Reset to move mode when placement mode is deactivated
return prev;
});
}, [canvasSignatureData]);
useEffect(() => {
if (!isPlacementMode && interactionMode === 'place') {
setInteractionMode('move');
}
}, [isPlacementMode, interactionMode]);
setSignatureDrafts(prev => {
if (imageSignatureData) {
if (prev.image === imageSignatureData) {
return prev;
}
return { ...prev, image: imageSignatureData };
}
if (prev.image !== undefined) {
const next = { ...prev };
delete next.image;
return next;
}
return prev;
});
}, [imageSignatureData]);
useEffect(() => {
const nextDraft = {
signerName: parameters.signerName || '',
fontSize: parameters.fontSize || 16,
fontFamily: parameters.fontFamily || 'Helvetica',
textColor: parameters.textColor || '#000000',
};
setSignatureDrafts(prev => {
const prevDraft = prev.text;
if (
prevDraft &&
prevDraft.signerName === nextDraft.signerName &&
prevDraft.fontSize === nextDraft.fontSize &&
prevDraft.fontFamily === nextDraft.fontFamily &&
prevDraft.textColor === nextDraft.textColor
) {
return prev;
}
return { ...prev, text: nextDraft };
});
}, [parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
useEffect(() => {
if (parameters.signatureType === 'text') {
const draft = signatureDrafts.text;
if (!draft) {
lastSyncedTextDraft.current = null;
return;
}
const currentSignerName = parameters.signerName ?? '';
const currentFontSize = parameters.fontSize ?? 16;
const currentFontFamily = parameters.fontFamily ?? 'Helvetica';
const currentTextColor = parameters.textColor ?? '#000000';
const isSynced =
draft.signerName === currentSignerName &&
draft.fontSize === currentFontSize &&
draft.fontFamily === currentFontFamily &&
draft.textColor === currentTextColor;
if (isSynced) {
lastSyncedTextDraft.current = draft;
return;
}
const lastSynced = lastSyncedTextDraft.current;
const alreadyAttempted =
lastSynced &&
lastSynced.signerName === draft.signerName &&
lastSynced.fontSize === draft.fontSize &&
lastSynced.fontFamily === draft.fontFamily &&
lastSynced.textColor === draft.textColor;
if (!alreadyAttempted) {
lastSyncedTextDraft.current = draft;
if (draft.signerName !== currentSignerName) {
onParameterChange('signerName', draft.signerName);
}
if (draft.fontSize !== currentFontSize) {
onParameterChange('fontSize', draft.fontSize);
}
if (draft.fontFamily !== currentFontFamily) {
onParameterChange('fontFamily', draft.fontFamily);
}
if (draft.textColor !== currentTextColor) {
onParameterChange('textColor', draft.textColor);
}
}
} else {
lastSyncedTextDraft.current = null;
}
}, [
parameters.signatureType,
parameters.signerName,
parameters.fontSize,
parameters.fontFamily,
parameters.textColor,
signatureDrafts.text,
onParameterChange,
]);
// Handle signature data updates
useEffect(() => {
let newSignatureData: string | undefined = undefined;
@@ -133,71 +333,99 @@ const SignSettings = ({
newSignatureData = canvasSignatureData;
}
// Only update if the signature data has actually changed
if (parameters.signatureData !== newSignatureData) {
onParameterChange('signatureData', newSignatureData);
}
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData]);
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData, onParameterChange]);
// Handle image signature activation - activate when image data syncs with parameters
useEffect(() => {
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
if (!shouldEnablePlacement) {
if (isPlacementMode) {
onDeactivateSignature?.();
}
if (isPlacementManuallyPaused) {
setPlacementManuallyPaused(false);
}
return;
}
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
// Handle canvas signature activation - activate when canvas data syncs with parameters
if (!shouldAutoActivate || isPlacementMode) {
return;
}
if (typeof window !== 'undefined') {
const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.();
}, PLACEMENT_ACTIVATION_DELAY);
return () => window.clearTimeout(timer);
}
onActivateSignaturePlacement?.();
}, [
shouldEnablePlacement,
shouldAutoActivate,
isPlacementMode,
isPlacementManuallyPaused,
onActivateSignaturePlacement,
onDeactivateSignature,
]);
useEffect(() => {
if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
if (!shouldAutoActivate || !placementSignatureKey) {
if (!shouldEnablePlacement || !shouldAutoActivate) {
lastAppliedPlacementKey.current = null;
}
return;
}
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData]);
// Draw settings are no longer needed since draw mode is removed
if (!isPlacementMode) {
lastAppliedPlacementKey.current = null;
return;
}
return (
<Stack gap="md">
{/* Signature Type Selection */}
<Tabs
value={parameters.signatureType}
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
>
<Tabs.List grow>
<Tabs.Tab value="canvas" style={{ fontSize: '0.8rem' }}>
{t('sign.type.canvas', 'Canvas')}
</Tabs.Tab>
<Tabs.Tab value="image" style={{ fontSize: '0.8rem' }}>
{t('sign.type.image', 'Image')}
</Tabs.Tab>
<Tabs.Tab value="text" style={{ fontSize: '0.8rem' }}>
{t('sign.type.text', 'Text')}
</Tabs.Tab>
</Tabs.List>
</Tabs>
if (lastAppliedPlacementKey.current === placementSignatureKey) {
return;
}
{/* Drawing Controls */}
<DrawingControls
onUndo={onUndo}
onRedo={onRedo}
onPlaceSignature={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
}}
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
disabled={disabled}
showPlaceButton={false}
placeButtonText={t('sign.updateAndPlace', 'Update and Place')}
/>
const trigger = () => {
onActivateSignaturePlacement?.();
lastAppliedPlacementKey.current = placementSignatureKey;
};
{/* Signature Creation based on type */}
{parameters.signatureType === 'canvas' && (
if (typeof window !== 'undefined') {
const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY);
return () => window.clearTimeout(timer);
}
trigger();
}, [placementSignatureKey, shouldAutoActivate, shouldEnablePlacement, isPlacementMode, onActivateSignaturePlacement]);
useEffect(() => {
if (activeFileIndex === previousFileIndexRef.current) {
return;
}
previousFileIndexRef.current = activeFileIndex;
if (!shouldEnablePlacement || signaturesApplied) {
return;
}
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
if (typeof window !== 'undefined') {
const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.();
}, FILE_SWITCH_ACTIVATION_DELAY);
return () => window.clearTimeout(timer);
}
onActivateSignaturePlacement?.();
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
const renderSignatureBuilder = () => {
if (parameters.signatureType === 'canvas') {
return (
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
@@ -207,92 +435,204 @@ const SignSettings = ({
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
onDrawingComplete={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
onActivateSignaturePlacement?.();
}}
disabled={disabled}
additionalButtons={
<Button
onClick={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
}}
color="blue"
variant="filled"
disabled={disabled || !canvasSignatureData}
>
{t('sign.updateAndPlace', 'Update and Place')}
</Button>
}
initialSignatureData={canvasSignatureData}
/>
)}
);
}
{parameters.signatureType === 'image' && (
if (parameters.signatureType === 'image') {
return (
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
/>
)}
);
}
{parameters.signatureType === 'text' && (
<TextInputWithFont
text={parameters.signerName || ''}
onTextChange={(text) => onParameterChange('signerName', text)}
fontSize={parameters.fontSize || 16}
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
)}
return (
<TextInputWithFont
text={parameters.signerName || ''}
onTextChange={(text) => onParameterChange('signerName', text)}
fontSize={parameters.fontSize || 16}
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
);
};
const placementInstructions = () => {
if (parameters.signatureType === 'canvas') {
return t('sign.instructions.canvas', 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.');
}
if (parameters.signatureType === 'image') {
return t('sign.instructions.image', 'After uploading your signature image, click anywhere on the PDF to place it.');
}
return t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.');
};
{/* Interaction Mode Toggle */}
{(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && (
<SegmentedControl
value={interactionMode}
onChange={(value) => {
setInteractionMode(value as 'move' | 'place');
if (value === 'place') {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
} else {
if (onDeactivateSignature) {
onDeactivateSignature();
}
}
}}
data={[
{ label: t('sign.mode.move', 'Move Signature'), value: 'move' },
{ label: t('sign.mode.place', 'Place Signature'), value: 'place' }
]}
fullWidth
/>
)}
const placementAlert = isCurrentTypeReady
? {
color: isPlacementMode ? 'blue' : 'teal',
title: isPlacementMode
? t('sign.instructions.title', 'How to add your signature')
: t('sign.instructions.paused', 'Placement paused'),
message: isPlacementMode
? placementInstructions()
: t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'),
}
: {
color: 'yellow',
title: t('sign.instructions.title', 'How to add your signature'),
message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'),
};
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
{parameters.signatureType === 'canvas' && t('sign.instructions.canvas', 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.')}
{parameters.signatureType === 'image' && t('sign.instructions.image', 'After uploading your signature image above, click anywhere on the PDF to place it.')}
{parameters.signatureType === 'text' && t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.')}
const handlePausePlacement = () => {
setPlacementManuallyPaused(true);
onDeactivateSignature?.();
};
const handleResumePlacement = () => {
setPlacementManuallyPaused(false);
onActivateSignaturePlacement?.();
};
// Handle Escape key to toggle pause/resume
useEffect(() => {
if (!isCurrentTypeReady) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
if (isPlacementMode) {
handlePausePlacement();
} else if (isPlacementManuallyPaused) {
handleResumePlacement();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isCurrentTypeReady, isPlacementMode, isPlacementManuallyPaused]);
const placementToggleControl =
onActivateSignaturePlacement || onDeactivateSignature
? isPlacementMode
? (
<Tooltip label={t('sign.mode.pause', 'Pause placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={t('sign.mode.pause', 'Pause placement')}
onClick={handlePausePlacement}
disabled={disabled || !onDeactivateSignature}
style={{
width: 'auto',
paddingInline: '0.75rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('sign.mode.pause', 'Pause placement')}
</Text>
</ActionIcon>
</Tooltip>
)
: (
<Tooltip label={t('sign.mode.resume', 'Resume placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={t('sign.mode.resume', 'Resume placement')}
onClick={handleResumePlacement}
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
style={{
width: 'auto',
paddingInline: '0.75rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('sign.mode.resume', 'Resume placement')}
</Text>
</ActionIcon>
</Tooltip>
)
: null;
return (
<Stack>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
</Text>
</Alert>
<SegmentedControl
value={parameters.signatureType}
fullWidth
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
data={[
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' },
{ label: t('sign.type.image', 'Upload'), value: 'image' },
{ label: t('sign.type.text', 'Type'), value: 'text' },
]}
/>
{renderSignatureBuilder()}
</Stack>
<Divider />
<Stack gap="sm">
<Text fw={600} size="md">
{t('sign.step.place', 'Place & save')}
</Text>
<Text size="sm" c="dimmed">
{t('sign.step.placeDesc', 'Position the signature on your PDF')}
</Text>
<Group gap="xs" wrap="nowrap" align="center">
<DrawingControls
onUndo={onUndo}
onRedo={onRedo}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
hasSignatureData={hasAnySignature}
disabled={disabled}
showPlaceButton={false}
/>
<Box style={{ marginLeft: 'auto' }}>
{placementToggleControl}
</Box>
</Group>
<Alert color={placementAlert.color} title={placementAlert.title}>
<Text size="sm">
{placementAlert.message}
</Text>
</Alert>
</Stack>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
/>
{/* Apply Signatures Button */}
{onSave && (
<Button
onClick={onSave}
@@ -304,7 +644,6 @@ const SignSettings = ({
</Button>
)}
{/* Suggested Tools Section */}
<SuggestedToolsSection />
</Stack>
);
@@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
export interface EmbedPdfViewerProps {
@@ -34,6 +35,7 @@ const EmbedPdfViewerContent = ({
setActiveFileIndex: externalSetActiveFileIndex,
}: EmbedPdfViewerProps) => {
const viewerRef = React.useRef<HTMLDivElement>(null);
const pdfContainerRef = useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
@@ -53,7 +55,7 @@ const EmbedPdfViewerContent = ({
}, [rotationState.rotation]);
// Get signature context
const { signatureApiRef, historyApiRef } = useSignature();
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Get current file from FileContext
const { selectors, state } = useFileState();
@@ -71,6 +73,9 @@ const EmbedPdfViewerContent = ({
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
const isPlacementOverlayActive = Boolean(
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
);
// Track which file tab is active
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
@@ -247,15 +252,17 @@ const EmbedPdfViewerContent = ({
) : (
<>
{/* EmbedPDF Viewer */}
<Box style={{
position: 'relative',
flex: 1,
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
transition: 'margin-right 0.3s ease'
}}>
<Box
ref={pdfContainerRef}
style={{
position: 'relative',
flex: 1,
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
@@ -268,6 +275,11 @@ const EmbedPdfViewerContent = ({
// Future: Handle signature completion
}}
/>
<SignaturePlacementOverlay
containerRef={pdfContainerRef}
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
/>
</Box>
</>
)}
@@ -1,4 +1,4 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useSignature } from '@app/contexts/SignatureContext';
@@ -9,6 +9,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
const { provides: historyApi } = useHistoryCapability();
const { provides: annotationApi } = useAnnotationCapability();
const { getImageData, storeImageData } = useSignature();
const restoringIds = useRef<Set<string>>(new Set());
// Monitor annotation events to detect when annotations are restored
useEffect(() => {
@@ -20,11 +21,52 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
// Store image data for all STAMP annotations immediately when created or modified
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
const storedImageData = getImageData(annotation.id);
if (!storedImageData || storedImageData !== annotation.imageSrc) {
if (!storedImageData) {
storeImageData(annotation.id, annotation.imageSrc);
}
}
if (annotation && annotation.type === 13 && annotation.id) {
// Prevent infinite loops when we recreate annotations
if (restoringIds.current.has(annotation.id)) {
restoringIds.current.delete(annotation.id);
return;
}
const storedImageData = getImageData(annotation.id);
// If EmbedPDF cropped the image (imageSrc changed), recreate annotation using stored data
if (storedImageData && annotation.imageSrc && annotation.imageSrc !== storedImageData) {
const newId = uuidV4();
restoringIds.current.add(newId);
storeImageData(newId, storedImageData);
const pageIndex = event.pageIndex ?? annotation.pageIndex ?? annotation.object?.pageIndex ?? 0;
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
try {
annotationApi.deleteAnnotation(pageIndex, annotation.id);
setTimeout(() => {
annotationApi.createAnnotation(pageIndex, {
type: annotation.type,
rect,
author: annotation.author || 'Digital Signature',
subject: annotation.subject || 'Digital Signature',
pageIndex,
id: newId,
created: annotation.created || new Date(),
imageSrc: storedImageData,
contents: storedImageData,
data: storedImageData,
appearance: storedImageData,
});
}, 50);
} catch (restoreError) {
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
}
return;
}
}
// Handle annotation restoration after undo operations
if (event.type === 'create' && event.committed) {
// Check if this is a STAMP annotation (signature) that might need image data restoration
@@ -102,6 +144,21 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
canRedo: () => {
return historyApi ? historyApi.canRedo() : false;
},
subscribe: (listener: () => void) => {
if (!historyApi?.onHistoryChange) {
return () => {};
}
const wrapped = () => listener();
const unsubscribe = historyApi.onHistoryChange(wrapped);
listener();
if (typeof unsubscribe === 'function') {
return unsubscribe;
}
return () => {};
},
}), [historyApi]);
return null; // This is a bridge component with no UI
@@ -191,7 +191,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
height: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
flex: 1,
minHeight: 0,
minWidth: 0,
@@ -287,8 +286,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
minHeight: 0,
minWidth: 0,
contain: 'strict',
display: 'flex',
justifyContent: 'center',
}}
>
<Scroller
@@ -1,12 +1,193 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { useSignature } from '@app/contexts/SignatureContext';
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { useViewer } from '@app/contexts/ViewerContext';
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
// This prevents rendering issues and ensures signatures are always visible and usable.
const MIN_SIGNATURE_DIMENSION = 12;
// Use 2x oversampling to improve text rendering quality (anti-aliasing) when generating signature images.
// This provides a good balance between visual fidelity and performance/memory usage.
const TEXT_OVERSAMPLE_FACTOR = 2;
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
if (!value || depth > 6) return undefined;
// Prevent circular references
if (typeof value === 'object' && visited.has(value)) {
return undefined;
}
if (typeof value === 'string') {
return value.startsWith('data:image') ? value : undefined;
}
if (typeof value === 'object') {
visited.add(value);
if (Array.isArray(value)) {
for (const entry of value) {
const result = extractDataUrl(entry, depth + 1, visited);
if (result) return result;
}
} else {
for (const key of Object.keys(value as Record<string, unknown>)) {
const result = extractDataUrl((value as Record<string, unknown>)[key], depth + 1, visited);
if (result) return result;
}
}
}
return undefined;
};
const createTextStampImage = (
config: SignParameters,
displaySize?: { width: number; height: number } | null
): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
const text = (config.signerName ?? '').trim();
if (!text) {
return null;
}
const fontSize = config.fontSize ?? 16;
const fontFamily = config.fontFamily ?? 'Helvetica';
const textColor = config.textColor ?? '#000000';
const paddingX = Math.max(4, Math.round(fontSize * 0.8));
const paddingY = Math.max(4, Math.round(fontSize * 0.6));
const measureCanvas = document.createElement('canvas');
const measureCtx = measureCanvas.getContext('2d');
if (!measureCtx) {
return null;
}
measureCtx.font = `${fontSize}px ${fontFamily}`;
const metrics = measureCtx.measureText(text);
const textWidth = Math.ceil(metrics.width);
const naturalWidth = Math.max(MIN_SIGNATURE_DIMENSION, textWidth + paddingX * 2);
const naturalHeight = Math.max(MIN_SIGNATURE_DIMENSION, Math.ceil(fontSize + paddingY * 2));
const scale =
displaySize && naturalWidth > 0 && naturalHeight > 0
? Math.min(displaySize.width / naturalWidth, displaySize.height / naturalHeight)
: 1;
const displayWidth = Math.max(MIN_SIGNATURE_DIMENSION, naturalWidth * scale);
const displayHeight = Math.max(MIN_SIGNATURE_DIMENSION, naturalHeight * scale);
const canvasWidth = Math.max(
MIN_SIGNATURE_DIMENSION,
Math.round(displayWidth * TEXT_OVERSAMPLE_FACTOR)
);
const canvasHeight = Math.max(
MIN_SIGNATURE_DIMENSION,
Math.round(displayHeight * TEXT_OVERSAMPLE_FACTOR)
);
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
const effectiveScale = scale * TEXT_OVERSAMPLE_FACTOR;
ctx.scale(effectiveScale, effectiveScale);
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const horizontalPadding = paddingX;
const verticalCenter = naturalHeight / 2;
ctx.fillText(text, horizontalPadding, verticalCenter);
return {
dataUrl: canvas.toDataURL('image/png'),
pixelWidth: canvasWidth,
pixelHeight: canvasHeight,
displayWidth,
displayHeight,
};
};
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
const { getZoomState } = useViewer();
const currentZoom = getZoomState()?.currentZoom ?? 1;
const lastStampImageRef = useRef<string | null>(null);
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
const factor = 1 / zoom;
return {
width: size.width * factor,
height: size.height * factor,
};
},
[currentZoom]
);
const applyStampDefaults = useCallback(
(imageSrc: string, subject: string, size?: { width: number; height: number }) => {
if (!annotationApi) return;
annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc,
subject,
...(size ? { imageSize: { width: size.width, height: size.height } } : {}),
});
}
},
[annotationApi]
);
const configureStampDefaults = useCallback(async () => {
if (!annotationApi || !signatureConfig) {
return;
}
try {
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
const textStamp = createTextStampImage(signatureConfig, placementPreviewSize);
if (textStamp) {
const displaySize =
placementPreviewSize ?? {
width: textStamp.displayWidth,
height: textStamp.displayHeight,
};
const pdfSize = cssToPdfSize(displaySize);
lastStampImageRef.current = textStamp.dataUrl;
applyStampDefaults(textStamp.dataUrl, `Text Signature - ${signatureConfig.signerName}`, pdfSize);
}
return;
}
if (signatureConfig.signatureData) {
const pdfSize = placementPreviewSize ? cssToPdfSize(placementPreviewSize) : undefined;
lastStampImageRef.current = signatureConfig.signatureData;
applyStampDefaults(signatureConfig.signatureData, `Digital Signature - ${signatureConfig.reason || 'Document signing'}`, pdfSize);
return;
}
} catch (error) {
console.error('Error preparing signature defaults:', error);
}
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations
@@ -108,58 +289,9 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
activateSignaturePlacementMode: () => {
if (!annotationApi || !signatureConfig) return;
try {
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
// Skip native text tools - always use stamp for consistent sizing
const activatedTool = null;
if (!activatedTool) {
// Create text image as stamp with actual pixel size matching desired display size
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
const baseFontSize = signatureConfig.fontSize || 16;
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
const textColor = signatureConfig.textColor || '#000000';
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
canvas.height = baseFontSize + 20;
ctx.fillStyle = textColor;
ctx.font = `${baseFontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
const dataURL = canvas.toDataURL();
// Deactivate and reactivate to force refresh
annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc: dataURL,
subject: `Text Signature - ${signatureConfig.signerName}`,
});
}
}
}
} else if (signatureConfig.signatureData) {
// Use stamp tool for image/canvas signatures
annotationApi.setActiveTool('stamp');
const activeTool = annotationApi.getActiveTool();
if (activeTool && activeTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc: signatureConfig.signatureData,
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
});
}
}
} catch (error) {
configureStampDefaults().catch((error) => {
console.error('Error activating signature tool:', error);
}
});
},
updateDrawSettings: (color: string, size: number) => {
@@ -230,7 +362,61 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return [];
}
},
}), [annotationApi, signatureConfig]);
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {
return;
}
const unsubscribe = annotationApi.onAnnotationEvent(event => {
if (event.type !== 'create' && event.type !== 'update') {
return;
}
const annotation: any = event.annotation;
const annotationId: string | undefined = annotation?.id;
if (!annotationId) {
return;
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
extractDataUrl(annotation.appearance) ||
extractDataUrl(annotation.stampData) ||
extractDataUrl(annotation.contents) ||
extractDataUrl(annotation.data) ||
extractDataUrl(annotation.customData) ||
extractDataUrl(annotation.asset);
const dataToStore = directData || lastStampImageRef.current;
if (dataToStore) {
storeImageData(annotationId, dataToStore);
}
});
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData]);
useEffect(() => {
if (!isPlacementMode) {
return;
}
let cancelled = false;
configureStampDefaults().catch((error) => {
if (!cancelled) {
console.error('Error updating signature defaults:', error);
}
});
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
return null; // This is a bridge component with no UI
@@ -0,0 +1,171 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box } from '@mantine/core';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
import { useSignature } from '@app/contexts/SignatureContext';
import {
MAX_PREVIEW_WIDTH_RATIO,
MAX_PREVIEW_HEIGHT_RATIO,
MAX_PREVIEW_WIDTH_REM,
MAX_PREVIEW_HEIGHT_REM,
MIN_SIGNATURE_DIMENSION_REM,
OVERLAY_EDGE_PADDING_REM,
} from '@app/constants/signConstants';
// Convert rem to pixels using browser's base font size (typically 16px)
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
interface SignaturePlacementOverlayProps {
containerRef: React.RefObject<HTMLElement | null>;
isActive: boolean;
signatureConfig: SignParameters | null;
}
export const SignaturePlacementOverlay: React.FC<SignaturePlacementOverlayProps> = ({
containerRef,
isActive,
signatureConfig,
}) => {
const [preview, setPreview] = useState<SignaturePreview | null>(null);
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
const { setPlacementPreviewSize } = useSignature();
useEffect(() => {
let cancelled = false;
const buildPreview = async () => {
try {
const value = await buildSignaturePreview(signatureConfig ?? null);
if (!cancelled) {
setPreview(value);
}
} catch (error) {
console.error('Failed to build signature preview:', error);
if (!cancelled) {
setPreview(null);
}
}
};
buildPreview();
return () => {
cancelled = true;
};
}, [signatureConfig]);
useEffect(() => {
const element = containerRef.current;
if (!isActive || !element) {
setCursor(null);
return;
}
const handleMove = (event: MouseEvent) => {
const rect = element.getBoundingClientRect();
setCursor({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
};
const handleLeave = () => setCursor(null);
element.addEventListener('mousemove', handleMove);
element.addEventListener('mouseleave', handleLeave);
return () => {
element.removeEventListener('mousemove', handleMove);
element.removeEventListener('mouseleave', handleLeave);
};
}, [containerRef, isActive]);
const scaledSize = useMemo(() => {
if (!preview || !containerRef.current) {
return null;
}
const container = containerRef.current;
const containerWidth = container.clientWidth || 1;
const containerHeight = container.clientHeight || 1;
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
const scale = Math.min(
1,
maxWidth / Math.max(preview.width, 1),
maxHeight / Math.max(preview.height, 1)
);
return {
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
};
}, [preview, containerRef]);
useEffect(() => {
if (!isActive || !scaledSize) {
setPlacementPreviewSize(null);
} else {
setPlacementPreviewSize(scaledSize);
}
}, [isActive, scaledSize, setPlacementPreviewSize]);
useEffect(() => {
return () => {
setPlacementPreviewSize(null);
};
}, [setPlacementPreviewSize]);
const display = useMemo(() => {
if (!preview || !scaledSize || !cursor || !containerRef.current) {
return null;
}
const container = containerRef.current;
const containerWidth = container.clientWidth || 1;
const containerHeight = container.clientHeight || 1;
const width = scaledSize.width;
const height = scaledSize.height;
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
return {
left: clampedLeft,
top: clampedTop,
width,
height,
dataUrl: preview.dataUrl,
};
}, [preview, scaledSize, cursor, containerRef]);
if (!isActive || !display || !preview) {
return null;
}
return (
<Box
style={{
position: 'absolute',
pointerEvents: 'none',
left: `${display.left}px`,
top: `${display.top}px`,
width: `${display.width}px`,
height: `${display.height}px`,
backgroundImage: `url(${display.dataUrl})`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
borderRadius: '4px',
transition: 'transform 70ms ease-out',
transform: 'translateZ(0)',
opacity: 0.6,
}}
/>
);
};
@@ -7,9 +7,7 @@ import {
determineAutoZoom,
DEFAULT_FALLBACK_ZOOM,
DEFAULT_VISIBILITY_THRESHOLD,
measureRenderedPageRect,
useFitWidthResize,
ZoomViewport,
} from '@app/utils/viewerZoom';
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
@@ -73,18 +71,6 @@ export function ZoomAPIBridge() {
}
}, [spreadMode, zoomState?.zoomLevel, scheduleAutoZoom, requestFitWidth]);
const getViewportSnapshot = useCallback((): ZoomViewport | null => {
if (!zoomState || typeof zoomState !== 'object') {
return null;
}
if ('viewport' in zoomState) {
const candidate = (zoomState as { viewport?: ZoomViewport | null }).viewport;
return candidate ?? null;
}
return null;
}, [zoomState]);
const isManagedZoom =
!!zoom &&
@@ -119,7 +105,7 @@ export function ZoomAPIBridge() {
}
const fitWidthZoom = zoomState.currentZoomLevel;
if (!fitWidthZoom || fitWidthZoom <= 0) {
if (!fitWidthZoom || fitWidthZoom <= 0 || fitWidthZoom === 1) {
return;
}
@@ -137,37 +123,23 @@ export function ZoomAPIBridge() {
const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1;
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
const viewport = getViewportSnapshot();
if (cancelled) {
return;
}
const metrics = viewport ?? {};
const viewportWidth =
metrics.clientWidth ?? metrics.width ?? window.innerWidth ?? 0;
const viewportHeight =
metrics.clientHeight ?? metrics.height ?? window.innerHeight ?? 0;
const viewportWidth = window.innerWidth ?? 0;
const viewportHeight = window.innerHeight ?? 0;
if (viewportWidth <= 0 || viewportHeight <= 0) {
return;
}
const pageRect = await measureRenderedPageRect({
shouldCancel: () => cancelled,
});
if (cancelled) {
return;
}
const decision = determineAutoZoom({
viewportWidth,
viewportHeight,
fitWidthZoom,
pagesPerSpread,
pageRect: pageRect
? { width: pageRect.width, height: pageRect.height }
: undefined,
pageRect: undefined,
metadataAspectRatio: metadataAspectRatio ?? null,
visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom: DEFAULT_FALLBACK_ZOOM,
@@ -197,7 +169,6 @@ export function ZoomAPIBridge() {
firstFileId,
firstFileStub,
requestFitWidth,
getViewportSnapshot,
autoZoomTick,
spreadMode,
triggerImmediateZoomUpdate,
@@ -243,4 +214,4 @@ export function ZoomAPIBridge() {
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
return null;
}
}
@@ -21,4 +21,5 @@ export interface HistoryAPI {
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void;
}
@@ -0,0 +1,15 @@
// Timeout delays (ms) to allow PDF viewer to complete rendering before activating placement mode
export const PLACEMENT_ACTIVATION_DELAY = 60; // Standard delay for signature changes
export const FILE_SWITCH_ACTIVATION_DELAY = 80; // Slightly longer delay when switching files
// Signature preview sizing
export const MAX_PREVIEW_WIDTH_RATIO = 0.35; // Max preview width as percentage of container
export const MAX_PREVIEW_HEIGHT_RATIO = 0.35; // Max preview height as percentage of container
export const MAX_PREVIEW_WIDTH_REM = 15; // Absolute max width in rem
export const MAX_PREVIEW_HEIGHT_REM = 10; // Absolute max height in rem
export const MIN_SIGNATURE_DIMENSION_REM = 0.75; // Min dimension for visibility
export const OVERLAY_EDGE_PADDING_REM = 0.25; // Padding from container edges
// Text signature padding (relative to font size)
export const HORIZONTAL_PADDING_RATIO = 0.8;
export const VERTICAL_PADDING_RATIO = 0.6;
@@ -39,6 +39,9 @@ export interface AppConfig {
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
error?: string;
}
@@ -0,0 +1,26 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface BannerContextType {
banner: ReactNode;
setBanner: (banner: ReactNode) => void;
}
const BannerContext = createContext<BannerContextType | undefined>(undefined);
export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState<ReactNode>(null);
return (
<BannerContext.Provider value={{ banner, setBanner }}>
{children}
</BannerContext.Provider>
);
}
export function useBanner() {
const context = useContext(BannerContext);
if (!context) {
throw new Error('useBanner must be used within BannerProvider');
}
return context;
}
@@ -10,6 +10,8 @@ interface SignatureState {
isPlacementMode: boolean;
// Whether signatures have been applied (allows export)
signaturesApplied: boolean;
// Size (in screen units) we want newly placed signatures to use
placementPreviewSize: { width: number; height: number } | null;
}
// Signature actions interface
@@ -26,6 +28,7 @@ interface SignatureActions {
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
setSignaturesApplied: (applied: boolean) => void;
setPlacementPreviewSize: (size: { width: number; height: number } | null) => void;
}
// Combined context interface
@@ -42,6 +45,7 @@ const initialState: SignatureState = {
signatureConfig: null,
isPlacementMode: false,
signaturesApplied: true, // Start as true (no signatures placed yet)
placementPreviewSize: null,
};
// Provider component
@@ -131,6 +135,27 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
}));
}, []);
const setPlacementPreviewSize = useCallback((size: { width: number; height: number } | null) => {
setState(prev => {
const prevSize = prev.placementPreviewSize;
const same =
(prevSize === null && size === null) ||
(prevSize !== null &&
size !== null &&
Math.abs(prevSize.width - size.width) < 0.5 &&
Math.abs(prevSize.height - size.height) < 0.5);
if (same) {
return prev;
}
return {
...prev,
placementPreviewSize: size,
};
});
}, []);
// No auto-activation - all modes use manual buttons
const contextValue: SignatureContextValue = {
@@ -149,6 +174,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
storeImageData,
getImageData,
setSignaturesApplied,
setPlacementPreviewSize,
};
return (
@@ -31,6 +31,7 @@ import AddWatermark from "@app/tools/AddWatermark";
import AddStamp from "@app/tools/AddStamp";
import AddAttachments from "@app/tools/AddAttachments";
import Merge from '@app/tools/Merge';
import EditTableOfContents from '@app/tools/EditTableOfContents';
import Repair from "@app/tools/Repair";
import AutoRename from "@app/tools/AutoRename";
import SingleLargePage from "@app/tools/SingleLargePage";
@@ -63,6 +64,7 @@ import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermiss
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
import { editTableOfContentsOperationConfig } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
@@ -345,6 +347,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: ChangeMetadataSingleStep,
synonyms: getSynonyms(t, "changeMetadata")
},
editTableOfContents: {
icon: <LocalIcon icon="toc-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: EditTableOfContents,
description: t(
"home.editTableOfContents.desc",
"Add or edit bookmarks and table of contents in PDF documents"
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: 1,
endpoints: ["edit-table-of-contents"],
operationConfig: editTableOfContentsOperationConfig,
automationSettings: null,
supportsAutomate: false,
synonyms: getSynonyms(t, "editTableOfContents"),
},
// Page Formatting
crop: {
@@ -689,16 +708,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "addImage"),
automationSettings: null
},
editTableOfContents: {
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: null,
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "editTableOfContents"),
automationSettings: null
},
scannerEffect: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.scannerEffect.title", "Scanner Effect"),
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { ToolType, type ToolOperationConfig, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { EditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters';
import { serializeBookmarkNodes } from '@app/utils/editTableOfContents';
const buildFormData = (parameters: EditTableOfContentsParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('replaceExisting', String(parameters.replaceExisting));
formData.append('bookmarkData', JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)));
return formData;
};
export const editTableOfContentsOperationConfig: ToolOperationConfig<EditTableOfContentsParameters> = {
toolType: ToolType.singleFile,
operationType: 'editTableOfContents',
endpoint: '/api/v1/general/edit-table-of-contents',
buildFormData,
};
export const useEditTableOfContentsOperation = () => {
const { t } = useTranslation();
return useToolOperation<EditTableOfContentsParameters>({
...editTableOfContentsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('editTableOfContents.error.failed', 'Failed to update the table of contents')
),
});
};
@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { BookmarkNode } from '@app/utils/editTableOfContents';
export interface EditTableOfContentsParameters {
replaceExisting: boolean;
bookmarks: BookmarkNode[];
}
export interface EditTableOfContentsParametersHook extends BaseParametersHook<EditTableOfContentsParameters> {
setBookmarks: (bookmarks: BookmarkNode[]) => void;
}
const defaultParameters: EditTableOfContentsParameters = {
replaceExisting: true,
bookmarks: [],
};
export const useEditTableOfContentsParameters = (): EditTableOfContentsParametersHook => {
const base = useBaseParameters<EditTableOfContentsParameters>({
defaultParameters,
endpointName: 'edit-table-of-contents',
});
const setBookmarks = useCallback((bookmarks: BookmarkNode[]) => {
base.setParameters(prev => ({
...prev,
bookmarks,
}));
}, [base.setParameters]);
return {
...base,
setBookmarks,
};
};
@@ -23,7 +23,7 @@ export const buildSignFormData = (params: SignParameters, file: File): FormData
}
// Add signature type
formData.append('signatureType', params.signatureType || 'draw');
formData.append('signatureType', params.signatureType || 'canvas');
// Add other parameters
if (params.reason) {
@@ -56,4 +56,4 @@ export const useSignOperation = (): ToolOperationHook<SignParameters> => {
...signOperationConfig,
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
});
};
};
@@ -9,7 +9,7 @@ export interface SignaturePosition {
}
export interface SignParameters {
signatureType: 'image' | 'text' | 'draw' | 'canvas';
signatureType: 'image' | 'text' | 'canvas';
signatureData?: string; // Base64 encoded image or text content
signaturePosition?: SignaturePosition;
reason?: string;
@@ -60,4 +60,4 @@ export const useSignParameters = () => {
endpointName: 'add-signature',
validateFn: validateSignParameters,
});
};
};
@@ -87,7 +87,7 @@ export function useTooltipPosition({
if (sidebarTooltip) {
// Require sidebar refs and state for proper positioning
if (!sidebarRefs || !sidebarState) {
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
console.warn('Sidebar tooltip requires sidebarRefs and sidebarState props');
setPositionReady(false);
return;
}
@@ -97,7 +97,7 @@ export function useTooltipPosition({
// Only show tooltip if we have the tool panel active
if (!sidebarInfo.isToolPanelActive) {
console.log('🚫 Not showing tooltip - tool panel not active');
console.log('Not showing tooltip - tool panel not active');
setPositionReady(false);
return;
}
+1
View File
@@ -7,6 +7,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
const apiClient = axios.create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
});
// Setup interceptors (core does nothing, proprietary adds JWT auth)
+11 -2
View File
@@ -1,5 +1,14 @@
import { AxiosInstance } from 'axios';
import { getBrowserId } from '@app/utils/browserIdentifier';
export function setupApiInterceptors(_client: AxiosInstance): void {
// Core version: no interceptors to add
export function setupApiInterceptors(client: AxiosInstance): void {
// Add browser ID header for WAU tracking
client.interceptors.request.use(
(config) => {
const browserId = getBrowserId();
config.headers['X-Browser-Id'] = browserId;
return config;
},
(error) => Promise.reject(error)
);
}
+186
View File
@@ -0,0 +1,186 @@
export interface UpdateSummary {
latest_version: string;
latest_stable_version?: string;
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
recommended_action?: string;
any_breaking: boolean;
migration_guides?: Array<{
version: string;
notes: string;
url: string;
}>;
}
export interface VersionUpdate {
version: string;
priority: 'urgent' | 'normal' | 'minor' | 'low';
announcement: {
title: string;
message: string;
};
compatibility: {
breaking_changes: boolean;
breaking_description?: string;
migration_guide_url?: string;
};
}
export interface FullUpdateInfo {
latest_version: string;
latest_stable_version?: string;
new_versions: VersionUpdate[];
}
export interface MachineInfo {
machineType: string;
activeSecurity: boolean;
licenseType: string;
}
export class UpdateService {
private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates';
/**
* Compare two version strings
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
compareVersions(version1: string, version2: string): number {
const v1 = version1.split('.');
const v2 = version2.split('.');
for (let i = 0; i < v1.length || i < v2.length; i++) {
const n1 = parseInt(v1[i]) || 0;
const n2 = parseInt(v2[i]) || 0;
if (n1 > n2) {
return 1;
} else if (n1 < n2) {
return -1;
}
}
return 0;
}
/**
* Get download URL based on machine type and security settings
*/
getDownloadUrl(machineInfo: MachineInfo): string | null {
// Only show download for non-Docker installations
if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') {
return null;
}
const baseUrl = 'https://files.stirlingpdf.com/';
// Determine file based on machine type and security
if (machineInfo.machineType === 'Server-jar') {
return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
}
// Client installations
if (machineInfo.machineType.startsWith('Client-')) {
const os = machineInfo.machineType.replace('Client-', ''); // win, mac, unix
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
if (os === 'unix') {
return baseUrl + os + type + '.jar';
} else if (os === 'win') {
return baseUrl + os + '-installer.exe';
} else if (os === 'mac') {
return baseUrl + os + '-installer.dmg';
}
}
return null;
}
/**
* Fetch update summary from API
*/
async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise<UpdateSummary | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`;
console.log('Fetching update summary from:', url);
try {
const response = await fetch(url);
console.log('Response status:', response.status);
if (response.status === 200) {
const data = await response.json();
return data as UpdateSummary;
} else {
console.error('Failed to fetch update summary from Supabase:', response.status);
return null;
}
} catch (error) {
console.error('Failed to fetch update summary from Supabase:', error);
return null;
}
}
/**
* Fetch full update information with detailed version info
*/
async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise<FullUpdateInfo | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`;
console.log('Fetching full update info from:', url);
try {
const response = await fetch(url);
console.log('Full update response status:', response.status);
if (response.status === 200) {
const data = await response.json();
return data as FullUpdateInfo;
} else {
console.error('Failed to fetch full update info from Supabase:', response.status);
return null;
}
} catch (error) {
console.error('Failed to fetch full update info from Supabase:', error);
return null;
}
}
/**
* Get current version from GitHub build.gradle as fallback
*/
async getCurrentVersionFromGitHub(): Promise<string> {
const url = 'https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle';
try {
const response = await fetch(url);
if (response.status === 200) {
const text = await response.text();
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
const match = versionRegex.exec(text);
if (match) {
return match[1];
}
}
throw new Error('Version number not found');
} catch (error) {
console.error('Failed to fetch latest version from build.gradle:', error);
return '';
}
}
}
export const updateService = new UpdateService();
+1 -1
View File
@@ -15,7 +15,7 @@ export const Z_INDEX_HOVER_ACTION_MENU = 100;
export const Z_INDEX_SELECTION_BOX = 1000;
export const Z_INDEX_DROP_INDICATOR = 1001;
export const Z_INDEX_DRAG_BADGE = 1001;
// Modal that appears on top of config modal (e.g., restart confirmation)
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
@@ -0,0 +1,394 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded';
import { alert } from '@app/components/toast';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import EditTableOfContentsWorkbenchView, { EditTableOfContentsWorkbenchViewData } from '@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView';
import EditTableOfContentsSettings from '@app/components/tools/editTableOfContents/EditTableOfContentsSettings';
import { useEditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters';
import { useEditTableOfContentsOperation } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
import apiClient from '@app/services/apiClient';
import { BookmarkPayload, BookmarkNode, hydrateBookmarkPayload, serializeBookmarkNodes } from '@app/utils/editTableOfContents';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
import { useFileSelection } from '@app/contexts/FileContext';
const extractBookmarks = async (file: File): Promise<BookmarkPayload[]> => {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/api/v1/general/extract-bookmarks', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data as BookmarkPayload[];
};
const useStableCallback = <T extends (...args: any[]) => any>(callback: T): T => {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useMemo(() => ((...args: Parameters<T>) => callbackRef.current(...args)) as T, []);
};
const EditTableOfContents = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'edit-table-of-contents',
useEditTableOfContentsParameters,
useEditTableOfContentsOperation,
props,
{ minFiles: 1 }
);
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const { openFilesModal } = useFilesModalContext();
const { clearSelections } = useFileSelection();
const navigationState = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const WORKBENCH_VIEW_ID = 'editTableOfContentsWorkbench';
const WORKBENCH_ID = 'custom:editTableOfContents' as const;
const viewIcon = useMemo(() => <MenuBookRoundedIcon fontSize="small" />, []);
const [loadError, setLoadError] = useState<string | null>(null);
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false);
const [lastLoadedFileId, setLastLoadedFileId] = useState<string | null>(null);
const hasAutoOpenedWorkbenchRef = useRef(false);
const selectedFile = base.selectedFiles[0];
const { setBookmarks } = base.params;
useEffect(() => {
registerCustomWorkbenchView({
id: WORKBENCH_VIEW_ID,
workbenchId: WORKBENCH_ID,
label: 'Outline workspace',
icon: viewIcon,
component: EditTableOfContentsWorkbenchView,
});
return () => {
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
};
// Register once; avoid re-registering which clears data mid-flight
}, []);
const loadBookmarksForFile = useCallback(async (file: File, { showToast }: { showToast?: boolean } = {}) => {
setIsLoadingBookmarks(true);
setLoadError(null);
try {
const payload = await extractBookmarks(file);
const bookmarks = hydrateBookmarkPayload(payload);
setBookmarks(bookmarks);
setLastLoadedFileId((file as any)?.fileId ?? file.name);
if (showToast) {
alert({
title: t('editTableOfContents.messages.loadedTitle', 'Bookmarks extracted'),
body: t('editTableOfContents.messages.loadedBody', 'Existing bookmarks from the PDF were loaded into the editor.'),
alertType: 'success',
});
}
if (bookmarks.length === 0) {
setLoadError(t('editTableOfContents.messages.noBookmarks', 'No bookmarks were found in the selected PDF.'));
}
} catch (error) {
console.error('Failed to load bookmarks', error);
setLoadError(t('editTableOfContents.messages.loadFailed', 'Unable to extract bookmarks from the selected PDF.'));
} finally {
setIsLoadingBookmarks(false);
}
}, [setBookmarks, t]);
useEffect(() => {
// Don't auto-load bookmarks if we have results - user is viewing the output
if (base.hasResults) {
return;
}
if (!selectedFile) {
setBookmarks([]);
setLastLoadedFileId(null);
setLoadError(null);
return;
}
const fileId = (selectedFile as any)?.fileId ?? selectedFile.name;
if (fileId === lastLoadedFileId) {
return;
}
loadBookmarksForFile(selectedFile).catch(() => {
// errors handled in hook
});
}, [selectedFile, lastLoadedFileId, loadBookmarksForFile, setBookmarks, base.hasResults]);
const importJsonCallback = async (file: File) => {
try {
const text = await file.text();
const json = JSON.parse(text) as BookmarkPayload[];
setBookmarks(hydrateBookmarkPayload(json));
alert({
title: t('editTableOfContents.messages.imported', 'Bookmarks imported'),
body: t('editTableOfContents.messages.importedBody', 'Your JSON outline replaced the current editor contents.'),
alertType: 'success',
});
} catch (error) {
console.error('Failed to import JSON bookmarks', error);
alert({
title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'),
body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'),
alertType: 'error',
});
}
};
const handleImportJson = useStableCallback(importJsonCallback);
const importClipboardCallback = async () => {
if (!navigator.clipboard?.readText) {
alert({
title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'),
alertType: 'warning',
});
return;
}
try {
const clipboard = await navigator.clipboard.readText();
const json = JSON.parse(clipboard) as BookmarkPayload[];
setBookmarks(hydrateBookmarkPayload(json));
alert({
title: t('editTableOfContents.messages.imported', 'Bookmarks imported'),
body: t('editTableOfContents.messages.importedClipboard', 'Clipboard data replaced the current bookmark list.'),
alertType: 'success',
});
} catch (error) {
console.error('Failed to import bookmarks from clipboard', error);
alert({
title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'),
body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'),
alertType: 'error',
});
}
};
const handleImportClipboard = useStableCallback(importClipboardCallback);
const exportJsonCallback = () => {
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'bookmarks.json';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
alert({
title: t('editTableOfContents.messages.exported', 'JSON download ready'),
alertType: 'success',
});
};
const handleExportJson = useStableCallback(exportJsonCallback);
const exportClipboardCallback = async () => {
if (!navigator.clipboard?.writeText) {
alert({
title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'),
alertType: 'warning',
});
return;
}
const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2);
try {
await navigator.clipboard.writeText(data);
alert({
title: t('editTableOfContents.messages.copied', 'Copied to clipboard'),
body: t('editTableOfContents.messages.copiedBody', 'Bookmark JSON copied successfully.'),
alertType: 'success',
});
} catch (error) {
console.error('Failed to copy bookmarks', error);
alert({
title: t('editTableOfContents.messages.copyFailed', 'Copy failed'),
alertType: 'error',
});
}
};
const handleExportClipboard = useStableCallback(exportClipboardCallback);
const clipboardReadAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText);
const clipboardWriteAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.writeText);
const loadFromSelectedCallback = () => {
if (selectedFile) {
loadBookmarksForFile(selectedFile, { showToast: true });
}
};
const handleLoadFromSelected = useStableCallback(loadFromSelectedCallback);
const replaceExistingCallback = (value: boolean) => {
base.params.updateParameter('replaceExisting', value);
};
const handleReplaceExistingChange = useStableCallback(replaceExistingCallback);
const bookmarksChangeCallback = (bookmarks: BookmarkNode[]) => {
setBookmarks(bookmarks);
};
const handleBookmarksChange = useStableCallback(bookmarksChangeCallback);
const executeCallback = () => {
void base.handleExecute();
};
const handleExecute = useStableCallback(executeCallback);
const undoCallback = () => {
base.handleUndo();
};
const handleUndo = useStableCallback(undoCallback);
const clearErrorCallback = () => {
base.operation.clearError();
};
const handleClearError = useStableCallback(clearErrorCallback);
const fileClickCallback = (file: File) => {
base.handleThumbnailClick(file);
};
const handleFileClick = useStableCallback(fileClickCallback);
const selectFilesCallback = () => {
// Clear existing selection first so the new file replaces instead of adds
clearSelections();
openFilesModal();
};
const handleSelectFiles = useStableCallback(selectFilesCallback);
// Always keep workbench data updated
useEffect(() => {
const data: EditTableOfContentsWorkbenchViewData = {
bookmarks: base.params.parameters.bookmarks,
selectedFileName: selectedFile?.name,
disabled: base.endpointLoading || base.operation.isLoading,
files: base.operation.files ?? [],
thumbnails: base.operation.thumbnails ?? [],
downloadUrl: base.operation.downloadUrl ?? null,
downloadFilename: base.operation.downloadFilename ?? null,
errorMessage: base.operation.errorMessage ?? null,
isGeneratingThumbnails: base.operation.isGeneratingThumbnails,
isExecuteDisabled:
!selectedFile ||
!base.hasFiles ||
base.endpointEnabled === false ||
base.operation.isLoading ||
base.endpointLoading,
isExecuting: base.operation.isLoading,
onClearError: handleClearError,
onBookmarksChange: handleBookmarksChange,
onExecute: handleExecute,
onUndo: handleUndo,
onFileClick: handleFileClick,
};
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, data);
}, [
WORKBENCH_VIEW_ID,
base.endpointEnabled,
base.endpointLoading,
base.hasFiles,
base.operation.downloadFilename,
base.operation.downloadUrl,
base.operation.errorMessage,
base.operation.files,
base.operation.isGeneratingThumbnails,
base.operation.isLoading,
base.operation.thumbnails,
base.params.parameters.bookmarks,
handleBookmarksChange,
handleClearError,
handleExecute,
handleFileClick,
handleUndo,
selectedFile,
setCustomWorkbenchViewData,
]);
// Auto-navigate to workbench when tool is selected
useEffect(() => {
if (navigationState.selectedTool !== 'editTableOfContents') {
hasAutoOpenedWorkbenchRef.current = false;
return;
}
if (hasAutoOpenedWorkbenchRef.current) {
return;
}
hasAutoOpenedWorkbenchRef.current = true;
// Use timeout to ensure data effect has run first
setTimeout(() => {
navigationActions.setWorkbench(WORKBENCH_ID);
}, 0);
}, [navigationActions, navigationState.selectedTool, WORKBENCH_ID]);
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: true,
minFiles: 1,
isVisible: false,
},
steps: [
{
title: t('editTableOfContents.settings.title', 'Bookmarks & outline'),
isCollapsed: false,
content: (
<EditTableOfContentsSettings
bookmarks={base.params.parameters.bookmarks}
replaceExisting={base.params.parameters.replaceExisting}
onReplaceExistingChange={handleReplaceExistingChange}
onSelectFiles={handleSelectFiles}
onLoadFromPdf={handleLoadFromSelected}
onImportJson={handleImportJson}
onImportClipboard={handleImportClipboard}
onExportJson={handleExportJson}
onExportClipboard={handleExportClipboard}
isLoading={isLoadingBookmarks}
loadError={loadError}
canReadClipboard={clipboardReadAvailable}
canWriteClipboard={clipboardWriteAvailable}
disabled={base.endpointLoading}
selectedFileName={selectedFile?.name}
/>
),
},
],
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t('editTableOfContents.results.title', 'Updated PDF with bookmarks'),
onFileClick: base.handleThumbnailClick,
onUndo: handleUndo,
},
});
};
(EditTableOfContents as any).tool = () => useEditTableOfContentsOperation;
export default EditTableOfContents as ToolComponent;
+28 -2
View File
@@ -115,6 +115,32 @@ const Sign = (props: BaseToolProps) => {
// Deactivate signature placement mode after everything completes
handleDeactivateSignature();
const hasSignatureReady = (() => {
const params = base.params.parameters;
switch (params.signatureType) {
case 'canvas':
case 'image':
return Boolean(params.signatureData);
case 'text':
return Boolean(params.signerName && params.signerName.trim() !== '');
default:
return false;
}
})();
if (hasSignatureReady) {
if (typeof window !== 'undefined') {
// TODO: Ideally, we should trigger handleActivateSignaturePlacement when the viewer is ready.
// However, due to current architectural constraints, we use a 150ms delay to allow the viewer to reload.
// This value was empirically determined to be sufficient for most environments, but should be revisited.
window.setTimeout(() => {
handleActivateSignaturePlacement();
}, 150);
} else {
handleActivateSignaturePlacement();
}
}
// File has been consumed - viewer should reload automatically via key prop
} else {
console.error('Signature flattening failed');
@@ -122,7 +148,7 @@ const Sign = (props: BaseToolProps) => {
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
const getSteps = () => {
const steps = [];
@@ -179,4 +205,4 @@ Sign.getDefaultParameters = () => ({
signerName: '',
});
export default Sign as ToolComponent;
export default Sign as ToolComponent;
@@ -0,0 +1,46 @@
/**
* Browser identifier utility for anonymous usage tracking
* Generates and persists a unique UUID in localStorage for WAU tracking
*/
const BROWSER_ID_KEY = 'stirling_browser_id';
/**
* Gets or creates a unique browser identifier
* Used for Weekly Active Users (WAU) tracking in no-login mode
*/
export function getBrowserId(): string {
try {
// Try to get existing ID from localStorage
let browserId = localStorage.getItem(BROWSER_ID_KEY);
if (!browserId) {
// Generate new UUID v4
browserId = generateUUID();
localStorage.setItem(BROWSER_ID_KEY, browserId);
}
return browserId;
} catch (error) {
// Fallback to session-based ID if localStorage is unavailable
console.warn('localStorage unavailable, using session-based ID', error);
return `session_${generateUUID()}`;
}
}
/**
* Generates a UUID v4
*/
function generateUUID(): string {
// Use crypto.randomUUID if available (modern browsers)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback to manual UUID generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
@@ -0,0 +1,47 @@
export interface BookmarkPayload {
title: string;
pageNumber: number;
children?: BookmarkPayload[];
}
export interface BookmarkNode {
id: string;
title: string;
pageNumber: number;
children: BookmarkNode[];
expanded: boolean;
}
const createBookmarkId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
export const createBookmarkNode = (bookmark?: Partial<BookmarkNode>): BookmarkNode => ({
id: bookmark?.id ?? createBookmarkId(),
title: bookmark?.title ?? '',
pageNumber: bookmark?.pageNumber ?? 1,
children: bookmark?.children ? bookmark.children.map(child => createBookmarkNode(child)) : [],
expanded: bookmark?.expanded ?? true,
});
export const hydrateBookmarkPayload = (payload: BookmarkPayload[] = []): BookmarkNode[] => {
return payload.map(item => ({
id: createBookmarkId(),
title: item.title ?? '',
pageNumber: typeof item.pageNumber === 'number' && item.pageNumber > 0 ? item.pageNumber : 1,
expanded: true,
children: item.children ? hydrateBookmarkPayload(item.children) : [],
}));
};
export const serializeBookmarkNodes = (bookmarks: BookmarkNode[]): BookmarkPayload[] => {
return bookmarks.map(bookmark => ({
title: bookmark.title,
pageNumber: bookmark.pageNumber,
children: serializeBookmarkNodes(bookmark.children),
}));
};
@@ -0,0 +1,84 @@
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/constants/signConstants';
export interface SignaturePreview {
dataUrl: string;
width: number;
height: number;
}
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
export const buildSignaturePreview = async (config: SignParameters | null): Promise<SignaturePreview | null> => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return null;
}
if (!config) {
return null;
}
if (config.signatureType === 'text') {
const text = config.signerName?.trim();
if (!text) {
return null;
}
const fontSize = config.fontSize ?? 16;
const fontFamily = config.fontFamily ?? 'Helvetica';
const textColor = config.textColor ?? '#000000';
const paddingX = Math.round(fontSize * HORIZONTAL_PADDING_RATIO);
const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO);
const measureCanvas = document.createElement('canvas');
const measureCtx = measureCanvas.getContext('2d');
if (!measureCtx) {
return null;
}
measureCtx.font = `${fontSize}px ${fontFamily}`;
const metrics = measureCtx.measureText(text);
const textWidth = Math.ceil(metrics.width);
const width = Math.max(1, textWidth + paddingX * 2);
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(text, paddingX, height / 2);
const dataUrl = canvas.toDataURL('image/png');
return { dataUrl, width, height };
}
const dataUrl = config.signatureData;
if (!dataUrl) {
return null;
}
const image = await loadImage(dataUrl);
return {
dataUrl,
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
};
};

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