From f22f697edc1c826ad09c6fffba83a022415e4c28 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:50:01 +0000 Subject: [PATCH 01/16] init user flags (#4875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Adds isNewServer and isNewUser flags to the /api/v1/app-config endpoint to enable differentiated onboarding experiences for first-time servers and users. When onboarding completes → call POST /api/v1/user/complete-initial-setup image --- ## 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. --- .../common/service/UserServiceInterface.java | 2 ++ .../software/SPDF/config/InitialSetup.java | 13 +++++++++ .../controller/api/misc/ConfigController.java | 17 ++++++++++++ .../controller/api/UserController.java | 27 +++++++++++++++++++ .../proprietary/security/model/User.java | 11 ++++++++ .../security/service/UserService.java | 15 +++++++++++ 6 files changed, 85 insertions(+) diff --git a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java index a833d4c848..074f422000 100644 --- a/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java +++ b/app/common/src/main/java/stirling/software/common/service/UserServiceInterface.java @@ -8,4 +8,6 @@ public interface UserServiceInterface { long getTotalUsersCount(); boolean isCurrentUserAdmin(); + + boolean isCurrentUserFirstLogin(); } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index 2d261c6609..0a63a6f486 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -28,6 +28,8 @@ public class InitialSetup { private final ApplicationProperties applicationProperties; + private static boolean isNewServer = false; + @PostConstruct public void init() throws IOException { initUUIDKey(); @@ -88,6 +90,13 @@ public class InitialSetup { } public void initSetAppVersion() throws IOException { + // Check if this is a new server before setting the version + String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion(); + isNewServer = + existingVersion == null + || existingVersion.isEmpty() + || existingVersion.equals("0.0.0"); + String appVersion = "0.0.0"; Resource resource = new ClassPathResource("version.properties"); Properties props = new Properties(); @@ -99,4 +108,8 @@ public class InitialSetup { GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion); applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion); } + + public static boolean isNewServer() { + return isNewServer; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index b578d7c428..02b5233b8b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.config.InitialSetup; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; @@ -78,6 +79,22 @@ public class ConfigController { } configData.put("isAdmin", isAdmin); + // Check if this is a new server (version was 0.0.0 before initialization) + configData.put("isNewServer", InitialSetup.isNewServer()); + + // Check if the current user is a first-time user + boolean isNewUser = + false; // Default to false when security is disabled or user not found + if (userService != null) { + try { + isNewUser = userService.isCurrentUserFirstLogin(); + } catch (Exception e) { + // If there's an error, assume not new user for safety + isNewUser = false; + } + } + configData.put("isNewUser", isNewUser); + // System settings configData.put( "enableAlphaFunctionality", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 92a1f82ac0..9f2ce94560 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -742,4 +742,31 @@ public class UserController { return errorMessage; } } + + @PostMapping("/complete-initial-setup") + public ResponseEntity completeInitialSetup() { + try { + String username = userService.getCurrentUsername(); + if (username == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body("User not authenticated"); + } + + Optional userOpt = userService.findByUsernameIgnoreCase(username); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found"); + } + + User user = userOpt.get(); + user.setHasCompletedInitialSetup(true); + userRepository.save(user); + + log.info("User {} completed initial setup", username); + return ResponseEntity.ok().body(Map.of("success", true)); + } catch (Exception e) { + log.error("Error completing initial setup", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Failed to complete initial setup"); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 02bd08a5bd..8f64d3187f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable { @Column(name = "isFirstLogin") private Boolean isFirstLogin = false; + @Column(name = "hasCompletedInitialSetup") + private Boolean hasCompletedInitialSetup = false; + @Column(name = "roleName") private String roleName; @@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable { this.isFirstLogin = isFirstLogin; } + public boolean hasCompletedInitialSetup() { + return hasCompletedInitialSetup != null && hasCompletedInitialSetup; + } + + public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) { + this.hasCompletedInitialSetup = hasCompletedInitialSetup; + } + public void setAuthenticationType(AuthenticationType authenticationType) { this.authenticationType = authenticationType.toString().toLowerCase(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index d13fcc0cdf..4772368f88 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface { return false; } + public boolean isCurrentUserFirstLogin() { + try { + String username = getCurrentUsername(); + if (username != null) { + Optional userOpt = findByUsernameIgnoreCase(username); + if (userOpt.isPresent()) { + return !userOpt.get().hasCompletedInitialSetup(); + } + } + } catch (Exception e) { + log.debug("Error checking first login status", e); + } + return false; + } + @Transactional public void syncCustomApiUser(String customApiKey) { if (customApiKey == null || customApiKey.trim().isBlank()) { From a5e2b542748b07baef1e7aa0c1596ba5eca65849 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:54:01 +0000 Subject: [PATCH 02/16] Feature/v2/compare tool (#4751) # Description of Changes - Addition of the compare tool - --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton --- .../main/resources/static/css/theme/theme.css | 8 + .../public/locales/en-GB/translation.json | 109 ++- .../public/locales/en-US/translation.json | 110 ++- .../public/locales/eu-ES/translation.json | 2 +- .../fileEditor/FileEditorThumbnail.tsx | 4 +- .../src/core/components/layout/Workbench.tsx | 10 +- .../components/pageEditor/PageThumbnail.tsx | 4 +- .../core/components/shared/AppConfigModal.tsx | 6 +- .../src/core/components/shared/Tooltip.tsx | 16 +- .../core/components/shared/TopControls.tsx | 3 +- .../core/components/toast/ToastContext.tsx | 9 +- .../core/components/toast/ToastRenderer.css | 7 + .../core/components/toast/ToastRenderer.tsx | 3 +- frontend/src/core/components/toast/index.ts | 27 +- frontend/src/core/components/toast/types.ts | 2 +- .../src/core/components/tools/ToolPanel.tsx | 4 +- .../tools/compare/CompareDocumentPane.tsx | 296 ++++++ .../compare/CompareNavigationDropdown.tsx | 229 +++++ .../tools/compare/CompareWorkbenchView.tsx | 421 ++++++++ .../core/components/tools/compare/compare.ts | 239 +++++ .../components/tools/compare/compareView.css | 515 ++++++++++ .../hooks/useCompareChangeNavigation.ts | 247 +++++ .../compare/hooks/useCompareHighlights.ts | 144 +++ .../compare/hooks/useComparePagePreviews.ts | 253 +++++ .../tools/compare/hooks/useComparePanZoom.ts | 895 ++++++++++++++++++ .../hooks/useCompareRightRailButtons.tsx | 191 ++++ .../tools/shared/FileStatusIndicator.tsx | 4 +- .../tools/shared/createToolFlow.tsx | 1 - .../components/tooltips/useOverlayPdfsTips.ts | 6 +- .../viewer/useViewerRightRailButtons.tsx | 5 +- .../src/core/contexts/FilesModalContext.tsx | 33 +- .../src/core/contexts/ToolWorkflowContext.tsx | 2 +- .../core/data/useTranslatedToolRegistry.tsx | 13 +- .../hooks/tools/compare/operationUtils.ts | 566 +++++++++++ .../tools/compare/useCompareOperation.ts | 559 +++++++++++ .../tools/compare/useCompareParameters.ts | 23 + frontend/src/core/hooks/useFileHandler.ts | 6 +- frontend/src/core/hooks/useIsMobile.ts | 9 + .../core/hooks/useProgressivePagePreviews.ts | 279 ++++++ .../core/hooks/useShouldShowWelcomeModal.ts | 4 +- frontend/src/core/pages/HomePage.tsx | 4 +- frontend/src/core/styles/theme.css | 44 + frontend/src/core/tools/Compare.tsx | 596 ++++++++++++ frontend/src/core/tools/ValidateSignature.tsx | 2 + frontend/src/core/types/compare.ts | 314 ++++++ .../src/core/utils/signatureFlattening.ts | 2 +- frontend/src/core/utils/textDiff.ts | 50 + frontend/src/core/workers/compareWorker.ts | 452 +++++++++ .../hooks/useShouldShowWelcomeModal.ts | 4 +- 49 files changed, 6651 insertions(+), 81 deletions(-) create mode 100644 frontend/src/core/components/tools/compare/CompareDocumentPane.tsx create mode 100644 frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx create mode 100644 frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx create mode 100644 frontend/src/core/components/tools/compare/compare.ts create mode 100644 frontend/src/core/components/tools/compare/compareView.css create mode 100644 frontend/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts create mode 100644 frontend/src/core/components/tools/compare/hooks/useCompareHighlights.ts create mode 100644 frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts create mode 100644 frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts create mode 100644 frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx create mode 100644 frontend/src/core/hooks/tools/compare/operationUtils.ts create mode 100644 frontend/src/core/hooks/tools/compare/useCompareOperation.ts create mode 100644 frontend/src/core/hooks/tools/compare/useCompareParameters.ts create mode 100644 frontend/src/core/hooks/useIsMobile.ts create mode 100644 frontend/src/core/hooks/useProgressivePagePreviews.ts create mode 100644 frontend/src/core/tools/Compare.tsx create mode 100644 frontend/src/core/types/compare.ts create mode 100644 frontend/src/core/utils/textDiff.ts create mode 100644 frontend/src/core/workers/compareWorker.ts diff --git a/app/core/src/main/resources/static/css/theme/theme.css b/app/core/src/main/resources/static/css/theme/theme.css index e1ef98c83d..70958b0eb2 100644 --- a/app/core/src/main/resources/static/css/theme/theme.css +++ b/app/core/src/main/resources/static/css/theme/theme.css @@ -7,6 +7,8 @@ --md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%); --md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%); --md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%); + /* Clear button disabled text color (default/light) */ + --spdf-clear-disabled-text: var(--md-sys-color-primary); /* Icon fill */ --md-sys-icon-fill-0: 'FILL' 0, 'wght' 500; --md-sys-icon-fill-1: 'FILL' 1, 'wght' 500; @@ -25,6 +27,12 @@ --md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12); } +/* Dark theme overrides */ +.dark-theme { + /* In dark mode, use a neutral grey for disabled Clear button text */ + --spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e); +} + .fill { font-variation-settings: var(--md-sys-icon-fill-1); } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 81e2f0c64c..3e50f5cee8 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -38,6 +38,7 @@ "language": { "direction": "ltr" }, + "cancel": "Cancel", "addPageNumbers": { "fontSize": "Font Size", "fontName": "Font Name", @@ -2157,15 +2158,99 @@ "tags": "differentiate,contrast,changes,analysis", "title": "Compare", "header": "Compare PDFs", - "highlightColor": { - "1": "Highlight Colour 1:", - "2": "Highlight Colour 2:" + "clearSelected": "Clear selected", + "clear": { + "confirmTitle": "Clear selected PDFs?", + "confirmBody": "This will close the current comparison and take you back to Active Files.", + "confirm": "Clear and return" }, - "document": { - "1": "Document 1", - "2": "Document 2" + "review": { + "title": "Comparison Result", + "actionsHint": "Review the comparison, switch document roles, or export the summary.", + "switchOrder": "Switch order", + "exportSummary": "Export summary" }, - "submit": "Compare", + "base": { + "label": "Original document", + "placeholder": "Select the original PDF" + }, + "comparison": { + "label": "Edited document", + "placeholder": "Select the edited PDF" + }, + "addFilesHint": "Add PDFs in the Files step to enable selection.", + "noFiles": "No PDFs available yet", + "pages": "Pages", + "selection": { + "originalEditedTitle": "Select Original and Edited PDFs" + }, + "original": { "label": "Original PDF" }, + "edited": { "label": "Edited PDF" }, + "swap": { + "confirmTitle": "Re-run comparison?", + "confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?", + "confirm": "Swap and Re-run" + }, + "cta": "Compare", + "loading": "Comparing...", + + "summary": { + "baseHeading": "Original document", + "comparisonHeading": "Edited document", + "pageLabel": "Page" + }, + "rendering": { + "pageNotReadyTitle": "Page not rendered yet", + "pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.", + "rendering": "rendering", + "inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", + "pagesRendered": "pages rendered", + "complete": "Page rendering complete" + }, + "dropdown": { + "deletionsLabel": "Deletions", + "additionsLabel": "Additions", + "deletions": "Deletions ({{count}})", + "additions": "Additions ({{count}})", + "searchPlaceholder": "Search changes...", + "noResults": "No changes found" + }, + "actions": { + "stackVertically": "Stack vertically", + "placeSideBySide": "Place side by side", + "zoomOut": "Zoom out", + "zoomIn": "Zoom in", + "resetView": "Reset view", + "unlinkScrollPan": "Unlink scroll and pan", + "linkScrollPan": "Link scroll and pan", + "unlinkScroll": "Unlink scroll", + "linkScroll": "Link scroll" + }, + "toasts": { + "unlinkedTitle": "Independent scroll & pan enabled", + "unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." + }, + "error": { + "selectRequired": "Select a original and edited document.", + "filesMissing": "Unable to locate the selected files. Please re-select them.", + "generic": "Unable to compare these files." + }, + "status": { + "extracting": "Extracting text...", + "processing": "Analysing differences...", + "complete": "Comparison ready" + }, + "longJob": { + "title": "Large comparison in progress", + "body": "These PDFs together exceed 2,000 pages. Processing can take several minutes." + }, + "slowOperation": { + "title": "Still working…", + "body": "This comparison is taking longer than usual. You can let it continue or cancel it.", + "cancel": "Cancel comparison" + }, + + "newLine": "new-line", "complex": { "message": "One or both of the provided documents are large files, accuracy of comparison may be reduced" }, @@ -2178,6 +2263,16 @@ "text": { "message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." } + }, + "too": { + "dissimilar": { + "message": "These documents appear highly dissimilar. Comparison was stopped to save time." + } + }, + "earlyDissimilarity": { + "title": "These PDFs look highly different", + "body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.", + "stopButton": "Stop comparison" } }, "certSign": { diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 2fa6fab558..8c787ee61f 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -2443,15 +2443,99 @@ "tags": "differentiate,contrast,changes,analysis", "title": "Compare", "header": "Compare PDFs", - "highlightColor": { - "1": "Highlight Color 1:", - "2": "Highlight Color 2:" + "clearSelected": "Clear selected", + "clear": { + "confirmTitle": "Clear selected PDFs?", + "confirmBody": "This will close the current comparison and take you back to Active Files.", + "confirm": "Clear and return" }, - "document": { - "1": "Document 1", - "2": "Document 2" + "review": { + "title": "Comparison Result", + "actionsHint": "Review the comparison, switch document roles, or export the summary.", + "switchOrder": "Switch order", + "exportSummary": "Export summary" }, - "submit": "Compare", + "base": { + "label": "Original document", + "placeholder": "Select the original PDF" + }, + "comparison": { + "label": "Edited document", + "placeholder": "Select the edited PDF" + }, + "addFilesHint": "Add PDFs in the Files step to enable selection.", + "noFiles": "No PDFs available yet", + "pages": "Pages", + "selection": { + "originalEditedTitle": "Select Original and Edited PDFs" + }, + "original": { "label": "Original PDF" }, + "edited": { "label": "Edited PDF" }, + "swap": { + "confirmTitle": "Re-run comparison?", + "confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?", + "confirm": "Swap and Re-run" + }, + "cta": "Compare", + "loading": "Comparing...", + + "summary": { + "baseHeading": "Original document", + "comparisonHeading": "Edited document", + "pageLabel": "Page" + }, + "rendering": { + "pageNotReadyTitle": "Page not rendered yet", + "pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.", + "rendering": "rendering", + "inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", + "pagesRendered": "pages rendered", + "complete": "Page rendering complete" + }, + "dropdown": { + "deletionsLabel": "Deletions", + "additionsLabel": "Additions", + "deletions": "Deletions ({{count}})", + "additions": "Additions ({{count}})", + "searchPlaceholder": "Search changes...", + "noResults": "No changes found" + }, + "actions": { + "stackVertically": "Stack vertically", + "placeSideBySide": "Place side by side", + "zoomOut": "Zoom out", + "zoomIn": "Zoom in", + "resetView": "Reset view", + "unlinkScrollPan": "Unlink scroll and pan", + "linkScrollPan": "Link scroll and pan", + "unlinkScroll": "Unlink scroll", + "linkScroll": "Link scroll" + }, + "toasts": { + "unlinkedTitle": "Independent scroll & pan enabled", + "unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." + }, + "error": { + "selectRequired": "Select a original and edited document.", + "filesMissing": "Unable to locate the selected files. Please re-select them.", + "generic": "Unable to compare these files." + }, + "status": { + "extracting": "Extracting text...", + "processing": "Analysing differences...", + "complete": "Comparison ready" + }, + "longJob": { + "title": "Large comparison in progress", + "body": "These PDFs together exceed 2,000 pages. Processing can take several minutes." + }, + "slowOperation": { + "title": "Still working…", + "body": "This comparison is taking longer than usual. You can let it continue or cancel it.", + "cancel": "Cancel comparison" + }, + + "newLine": "new-line", "complex": { "message": "One or both of the provided documents are large files, accuracy of comparison may be reduced" }, @@ -2464,6 +2548,16 @@ "text": { "message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." } + }, + "too": { + "dissimilar": { + "message": "These documents appear highly dissimilar. Comparison was stopped to save time." + } + }, + "earlyDissimilarity": { + "title": "These PDFs look highly different", + "body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.", + "stopButton": "Stop comparison" } }, "certSign": { @@ -3115,7 +3209,7 @@ "title": "Overlay PDFs", "desc": "Overlay one PDF on top of another", "baseFile": { - "label": "Select Base PDF File" + "label": "Select Original PDF File" }, "overlayFiles": { "label": "Select Overlay PDF Files", diff --git a/frontend/public/locales/eu-ES/translation.json b/frontend/public/locales/eu-ES/translation.json index 963783e50a..61f942ed59 100644 --- a/frontend/public/locales/eu-ES/translation.json +++ b/frontend/public/locales/eu-ES/translation.json @@ -2986,7 +2986,7 @@ "title": "Overlay PDFs", "desc": "Overlay one PDF on top of another", "baseFile": { - "label": "Select Base PDF File" + "label": "Select Original PDF File" }, "overlayFiles": { "label": "Select Overlay PDF Files", diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index cc61246363..b51152af1a 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core'; -import { useMediaQuery } from '@mantine/hooks'; +import { useIsMobile } from '@app/hooks/useIsMobile'; import { alert } from '@app/components/toast'; import { useTranslation } from 'react-i18next'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; @@ -64,7 +64,7 @@ const FileEditorThumbnail = ({ const [isDragging, setIsDragging] = useState(false); const dragElementRef = useRef(null); const [showHoverMenu, setShowHoverMenu] = useState(false); - const isMobile = useMediaQuery('(max-width: 1024px)'); + const isMobile = useIsMobile(); const [showCloseModal, setShowCloseModal] = useState(false); // Resolve the actual File object for pin/unpin operations diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index dcd9b5366d..4b3d1ebc80 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -81,6 +81,7 @@ export default function Workbench() { switch (currentView) { case "fileEditor": + return ( view.workbenchId === currentView && view.data != null); + + if (customView) { const CustomComponent = customView.component; return ; @@ -154,7 +159,7 @@ export default function Workbench() { return ( 0 ? '3.5rem' : '0'), + overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined, }} > {renderMainContent()} diff --git a/frontend/src/core/components/pageEditor/PageThumbnail.tsx b/frontend/src/core/components/pageEditor/PageThumbnail.tsx index 2546a607ed..1a33e779b0 100644 --- a/frontend/src/core/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/core/components/pageEditor/PageThumbnail.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; import { Text, Checkbox } from '@mantine/core'; -import { useMediaQuery } from '@mantine/hooks'; +import { useIsMobile } from '@app/hooks/useIsMobile'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import RotateLeftIcon from '@mui/icons-material/RotateLeft'; @@ -69,7 +69,7 @@ const PageThumbnail: React.FC = ({ const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); const [isHovered, setIsHovered] = useState(false); - const isMobile = useMediaQuery('(max-width: 1024px)'); + const isMobile = useIsMobile(); const dragElementRef = useRef(null); const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 97f1ee295b..be13be2d42 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -1,12 +1,12 @@ import React, { useMemo, useState, useEffect } from 'react'; import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core'; -import { useMediaQuery } from '@mantine/hooks'; import { useNavigate, useLocation } from 'react-router-dom'; import LocalIcon from '@app/components/shared/LocalIcon'; import { createConfigNavSections } from '@app/components/shared/config/configNavSections'; import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import '@app/components/shared/AppConfigModal.css'; +import { useIsMobile } from '@app/hooks/useIsMobile'; import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; interface AppConfigModalProps { @@ -15,10 +15,10 @@ interface AppConfigModalProps { } const AppConfigModal: React.FC = ({ opened, onClose }) => { + const [active, setActive] = useState('general'); + const isMobile = useIsMobile(); const navigate = useNavigate(); const location = useLocation(); - const [active, setActive] = useState('general'); - const isMobile = useMediaQuery("(max-width: 1024px)"); const { config } = useAppConfig(); // Extract section from URL path (e.g., /settings/people -> people) diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 77db24d466..502430e53d 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -65,6 +65,10 @@ export const Tooltip: React.FC = ({ const clickPendingRef = useRef(false); const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); + // Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target + const isDomNode = (value: unknown): value is Node => + typeof Node !== 'undefined' && value instanceof Node; + const clearTimers = useCallback(() => { if (openTimeoutRef.current) { clearTimeout(openTimeoutRef.current); @@ -103,9 +107,9 @@ export const Tooltip: React.FC = ({ (e: MouseEvent) => { const tEl = tooltipRef.current; const trg = triggerRef.current; - const target = e.target as Node | null; - const insideTooltip = tEl && target && tEl.contains(target); - const insideTrigger = trg && target && trg.contains(target); + const target = e.target as unknown; + const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target)); + const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target)); // If pinned: only close when clicking outside BOTH tooltip & trigger if (isPinned) { @@ -172,7 +176,7 @@ export const Tooltip: React.FC = ({ const related = e.relatedTarget as Node | null; // Moving into the tooltip → keep open - if (related && tooltipRef.current && tooltipRef.current.contains(related)) { + if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { (children.props as any)?.onPointerLeave?.(e); return; } @@ -236,7 +240,7 @@ export const Tooltip: React.FC = ({ const handleBlur = useCallback( (e: React.FocusEvent) => { const related = e.relatedTarget as Node | null; - if (related && tooltipRef.current && tooltipRef.current.contains(related)) { + if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { (children.props as any)?.onBlur?.(e); return; } @@ -258,7 +262,7 @@ export const Tooltip: React.FC = ({ const handleTooltipPointerLeave = useCallback( (e: React.PointerEvent) => { const related = e.relatedTarget as Node | null; - if (related && triggerRef.current && triggerRef.current.contains(related)) return; + if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; if (!isPinned) setOpen(false); }, [isPinned, setOpen] diff --git a/frontend/src/core/components/shared/TopControls.tsx b/frontend/src/core/components/shared/TopControls.tsx index 64c78b8f18..e62d00716c 100644 --- a/frontend/src/core/components/shared/TopControls.tsx +++ b/frontend/src/core/components/shared/TopControls.tsx @@ -168,7 +168,8 @@ const TopControls = ({ return (
-
+
+ = 100 && !t.justCompleted) { - // On completion: finalize type as success unless explicitly provided otherwise - next.justCompleted = false; - if (!updates.alertType) { - next.alertType = 'success'; - } + next.justCompleted = true; } return next; diff --git a/frontend/src/core/components/toast/ToastRenderer.css b/frontend/src/core/components/toast/ToastRenderer.css index 09eb9e26c5..0cf2aac096 100644 --- a/frontend/src/core/components/toast/ToastRenderer.css +++ b/frontend/src/core/components/toast/ToastRenderer.css @@ -31,6 +31,13 @@ flex-direction: column-reverse; } +.toast-container--bottom-center { + bottom: 16px; + left: 50%; + transform: translateX(-50%); + flex-direction: column-reverse; +} + /* Toast Item Styles */ .toast-item { min-width: 320px; diff --git a/frontend/src/core/components/toast/ToastRenderer.tsx b/frontend/src/core/components/toast/ToastRenderer.tsx index 825ad3591e..09891099f0 100644 --- a/frontend/src/core/components/toast/ToastRenderer.tsx +++ b/frontend/src/core/components/toast/ToastRenderer.tsx @@ -8,6 +8,7 @@ const locationToClass: Record = { 'top-right': 'toast-container--top-right', 'bottom-left': 'toast-container--bottom-left', 'bottom-right': 'toast-container--bottom-right', + 'bottom-center': 'toast-container--bottom-center', }; function getToastItemClass(t: ToastInstance): string { @@ -44,7 +45,7 @@ export default function ToastRenderer() { if (!acc[key]) acc[key] = [] as ToastInstance[]; acc[key].push(t); return acc; - }, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] }); + }, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] }); return ( <> diff --git a/frontend/src/core/components/toast/index.ts b/frontend/src/core/components/toast/index.ts index e13ab38018..39f6093ea4 100644 --- a/frontend/src/core/components/toast/index.ts +++ b/frontend/src/core/components/toast/index.ts @@ -1,4 +1,4 @@ -import { ToastOptions } from '@app/components/toast/types'; +import { ToastApi, ToastInstance, ToastOptions } from '@app/components/toast/types'; import { useToast, ToastProvider } from '@app/components/toast/ToastContext'; import ToastRenderer from '@app/components/toast/ToastRenderer'; @@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer }; // Global imperative API via module singleton let _api: ReturnType | null = null; +type ToastContextApi = ToastApi & { toasts: ToastInstance[] }; + function createImperativeApi() { - const subscribers: Array<(fn: any) => void> = []; - let api: any = null; + const subscribers: Array<(fn: ToastContextApi) => void> = []; + let api: ToastContextApi | null = null; return { - provide(instance: any) { + provide(instance: ToastContextApi) { api = instance; - subscribers.splice(0).forEach(cb => cb(api)); + subscribers.splice(0).forEach(cb => cb(instance)); + }, + get(): ToastContextApi | null { + return api; + }, + onReady(cb: (readyApi: ToastContextApi) => void) { + if (api) { + cb(api); + } else { + subscribers.push(cb); + } }, - get(): any | null { return api; }, - onReady(cb: (api: any) => void) { - if (api) cb(api); else subscribers.push(cb); - } }; } @@ -58,4 +66,3 @@ export function dismissAllToasts() { _api?.get()?.dismissAll(); } - diff --git a/frontend/src/core/components/toast/types.ts b/frontend/src/core/components/toast/types.ts index aeb0c79a53..764e14c34c 100644 --- a/frontend/src/core/components/toast/types.ts +++ b/frontend/src/core/components/toast/types.ts @@ -1,6 +1,6 @@ import { ReactNode } from 'react'; -export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'bottom-center'; export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral'; export interface ToastOptions { diff --git a/frontend/src/core/components/tools/ToolPanel.tsx b/frontend/src/core/components/tools/ToolPanel.tsx index 4b6ea3eb40..a61739d009 100644 --- a/frontend/src/core/components/tools/ToolPanel.tsx +++ b/frontend/src/core/components/tools/ToolPanel.tsx @@ -10,7 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext"; import rainbowStyles from '@app/styles/rainbow.module.css'; import { ActionIcon, ScrollArea } from '@mantine/core'; import { ToolId } from '@app/types/toolId'; -import { useMediaQuery } from '@mantine/hooks'; +import { useIsMobile } from '@app/hooks/useIsMobile'; import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; import { useTranslation } from 'react-i18next'; import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface'; @@ -26,7 +26,7 @@ export default function ToolPanel() { const { isRainbowMode } = useRainbowThemeContext(); const { sidebarRefs } = useSidebarContext(); const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs; - const isMobile = useMediaQuery('(max-width: 1024px)'); + const isMobile = useIsMobile(); const { leftPanelView, diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx new file mode 100644 index 0000000000..78f9a4b75c --- /dev/null +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -0,0 +1,296 @@ +import { Group, Loader, Stack, Text } from '@mantine/core'; +import { useMemo, useRef, useEffect } from 'react'; +import type { PagePreview } from '@app/types/compare'; +import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare'; +import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare'; +import CompareNavigationDropdown from '@app/components/tools/compare/CompareNavigationDropdown'; +import { useIsMobile } from '@app/hooks/useIsMobile'; + +// utilities moved to compare.ts + +const CompareDocumentPane = ({ + pane, + layout, + scrollRef, + peerScrollRef, + handleScrollSync, + handleWheelZoom, + handleWheelOverscroll, + onTouchStart, + onTouchMove, + onTouchEnd, + isPanMode, + zoom, + title, + dropdownPlaceholder, + changes, + onNavigateChange, + isLoading, + processingMessage, + pages, + pairedPages, + wordHighlightMap, + metaIndexToGroupId, + documentLabel, + pageLabel, + altLabel, + onVisiblePageChange, +}: CompareDocumentPaneProps) => { + const isMobileViewport = useIsMobile(); + const pairedPageMap = useMemo(() => { + const map = new Map(); + pairedPages.forEach((item) => { + map.set(item.pageNumber, item); + }); + return map; + }, [pairedPages]); + + const HIGHLIGHT_BG_VAR = pane === 'base' ? 'var(--spdf-compare-removed-bg)' : 'var(--spdf-compare-added-bg)'; + const OFFSET_PIXELS = pane === 'base' ? 4 : 2; + const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto'; + const pagePanRef = useRef>(new Map()); + const dragRef = useRef<{ active: boolean; page: number | null; startX: number; startY: number; startPanX: number; startPanY: number }>({ active: false, page: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 }); + + // Track which page images have finished loading to avoid flashing between states + const imageLoadedRef = useRef>(new Map()); + const visiblePageRafRef = useRef(null); + const lastReportedVisiblePageRef = useRef(null); + const pageNodesRef = useRef(null); + const groupedRectsByPage = useMemo(() => { + const out = new Map>(); + for (const p of pages) { + const rects = wordHighlightMap.get(p.pageNumber) ?? []; + out.set(p.pageNumber, groupWordRects(rects, metaIndexToGroupId, pane)); + } + return out; + }, [pages, wordHighlightMap, metaIndexToGroupId, pane]); + + // When zoom returns to 1 (reset), clear per-page pan state so content is centered again + useEffect(() => { + if (zoom <= 1) { + pagePanRef.current.clear(); + } + }, [zoom]); + + return ( +
+
+ + + {title} + + + {(changes.length > 0 || Boolean(dropdownPlaceholder)) && ( + new Set(pages.map(p => p.pageNumber)), [pages])} + /> + )} + + +
+ +
{ + handleScrollSync(event.currentTarget, peerScrollRef.current); + // Notify parent about the currently visible page (throttled via rAF) + if (visiblePageRafRef.current != null) return; + if (!onVisiblePageChange || pages.length === 0) return; + visiblePageRafRef.current = requestAnimationFrame(() => { + const container = scrollRef.current; + if (!container) return; + const mid = container.scrollTop + container.clientHeight * 0.5; + let bestPage = pages[0]?.pageNumber ?? 1; + let bestDist = Number.POSITIVE_INFINITY; + let nodes = pageNodesRef.current; + if (!nodes || nodes.length !== pages.length) { + nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[]; + pageNodesRef.current = nodes; + } + for (const el of nodes) { + const top = el.offsetTop; + const height = el.clientHeight || 1; + const center = top + height / 2; + const dist = Math.abs(center - mid); + if (dist < bestDist) { + bestDist = dist; + const attr = el.getAttribute('data-page-number'); + const pn = attr ? parseInt(attr, 10) : NaN; + if (!Number.isNaN(pn)) bestPage = pn; + } + } + if (typeof onVisiblePageChange === 'function' && bestPage !== lastReportedVisiblePageRef.current) { + lastReportedVisiblePageRef.current = bestPage; + onVisiblePageChange(pane, bestPage); + } + visiblePageRafRef.current = null; + }); + }} + onMouseDown={undefined} + onMouseMove={undefined} + onMouseUp={undefined} + onMouseLeave={undefined} + onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }} + onTouchStart={(event) => onTouchStart(pane, event)} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} + className="compare-pane__scroll" + style={{ cursor: cursorStyle }} + > + + {isLoading && ( + + + {processingMessage} + + )} + + {pages.map((page) => { + const peerPage = pairedPageMap.get(page.pageNumber); + const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200; + const metrics = computePageLayoutMetrics({ + page, + peerPage: peerPage ?? null, + layout, + isMobileViewport, + scrollRefWidth: scrollRef.current?.clientWidth ?? null, + viewportWidth, + zoom, + offsetPixels: OFFSET_PIXELS, + }); + + const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics; + + // Compute clamped pan for current zoom so content always touches edges when in bounds + const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 }; + const contentWidth = Math.max(0, Math.round(containerWidth * innerScale)); + const contentHeight = Math.max(0, Math.round(containerHeight * innerScale)); + const maxPanX = Math.max(0, contentWidth - Math.round(containerWidth)); + const maxPanY = Math.max(0, contentHeight - Math.round(containerHeight)); + const clampedPanX = Math.max(0, Math.min(maxPanX, storedPan.x)); + const clampedPanY = Math.max(0, Math.min(maxPanY, storedPan.y)); + + const groupedRects = groupedRectsByPage.get(page.pageNumber) ?? new Map(); + + return ( + <> +
+
+ + {documentLabel} · {pageLabel} {page.pageNumber} + +
+
{ + if (!isPanMode || zoom <= 1) return; + dragRef.current.active = true; + dragRef.current.page = page.pageNumber; + dragRef.current.startX = e.clientX; + dragRef.current.startY = e.clientY; + const curr = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 }; + dragRef.current.startPanX = curr.x; + dragRef.current.startPanY = curr.y; + (e.currentTarget as HTMLElement).style.cursor = 'grabbing'; + e.preventDefault(); + }} + onMouseMove={(e) => { + if (!dragRef.current.active || dragRef.current.page !== page.pageNumber) return; + const dx = e.clientX - dragRef.current.startX; + const dy = e.clientY - dragRef.current.startY; + // Clamp panning based on the actual rendered content size. + // The inner layer is width/height of the container, then scaled by innerScale. + const contentWidth = Math.max(0, Math.round(containerWidth * innerScale)); + const contentHeight = Math.max(0, Math.round(containerHeight * innerScale)); + const maxX = Math.max(0, contentWidth - Math.round(containerWidth)); + const maxY = Math.max(0, contentHeight - Math.round(containerHeight)); + const candX = dragRef.current.startPanX - dx; + const candY = dragRef.current.startPanY - dy; + const next = { x: Math.max(0, Math.min(maxX, candX)), y: Math.max(0, Math.min(maxY, candY)) }; + pagePanRef.current.set(page.pageNumber, next); + e.preventDefault(); + }} + onMouseUp={(e) => { + if (dragRef.current.active) { + dragRef.current.active = false; + (e.currentTarget as HTMLElement).style.cursor = cursorStyle; + } + }} + onMouseLeave={(e) => { + if (dragRef.current.active) { + dragRef.current.active = false; + (e.currentTarget as HTMLElement).style.cursor = cursorStyle; + } + }} + > +
+ {/* Image layer */} + {altLabel} { + if (!imageLoadedRef.current.get(page.pageNumber)) { + imageLoadedRef.current.set(page.pageNumber, true); + } + }} + /> + {/* Overlay loader until the page image is loaded */} + {!((imageLoadedRef.current.get(page.pageNumber) ?? false)) && ( +
+ +
+ )} + {[...groupedRects.entries()].flatMap(([id, rects]) => + mergeConnectedRects(rects).map((rect, index) => { + const rotation = normalizeRotation(page.rotation); + const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset; + return ( + + ); + }) + )} +
+
+
+ + ); + })} +
+
+
+ ); +}; + +export default CompareDocumentPane; diff --git a/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx b/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx new file mode 100644 index 0000000000..ab7a746897 --- /dev/null +++ b/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx @@ -0,0 +1,229 @@ +import { Combobox, ScrollArea, useCombobox } from '@mantine/core'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { NavigationDropdownProps } from '@app/types/compare'; + +const CompareNavigationDropdown = ({ + changes, + placeholder, + className, + onNavigate, + renderedPageNumbers, +}: NavigationDropdownProps) => { + const { t } = useTranslation(); + const newLineLabel = t('compare.newLine', 'new-line'); + const combobox = useCombobox({ + onDropdownClose: () => { + combobox.resetSelectedOption(); + // Cache scrollTop so we can restore on next open + const viewport = viewportRef.current; + if (viewport) scrollTopRef.current = viewport.scrollTop; + setIsOpen(false); + }, + onDropdownOpen: () => { + setIsOpen(true); + // Restore scrollTop after mount and rebuild offsets + requestAnimationFrame(() => { + const viewport = viewportRef.current; + if (viewport) viewport.scrollTop = scrollTopRef.current; + const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[]; + groupOffsetsRef.current = headers.map((el) => { + const text = el.textContent || ''; + const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0; + return { top: el.offsetTop, page }; + }); + // Update sticky label based on current scroll position + handleScrollPos({ x: 0, y: scrollTopRef.current }); + }); + }, + }); + + const sanitize = (s: string) => { + // Normalize and remove control/separator characters without regex ranges + return s + .normalize('NFKC') + .split('') + .map(char => { + const code = char.charCodeAt(0); + // Replace control chars (0-31, 127) and special separators with space + if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) { + return ' '; + } + return char; + }) + .join('') + .replace(/\s+/g, ' ') + .trim(); + }; + + const isMeaningful = (s: string) => { + const t = sanitize(s); + + // Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class. + const rx = + (() => { + try { + // Construct at runtime so old engines don’t fail parse-time + return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u'); + } catch { + // Fallback (no Unicode props): letters, digits, and common punctuation/symbols + return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/; + } + })(); + + if (!rx.test(t)) return false; + return t.length > 0; + }; + + + const [query, setQuery] = useState(''); + const viewportRef = useRef(null); + const [stickyPage, setStickyPage] = useState(null); + const groupOffsetsRef = useRef>([]); + const scrollTopRef = useRef(0); + const [isOpen, setIsOpen] = useState(false); + + const normalizedChanges = useMemo(() => { + // Helper to strip localized new-line marker occurrences from labels + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const stripNewLine = (s: string) => + s + .replace(new RegExp(`\\b${esc(newLineLabel)}\\b`, 'gi'), ' ') + .replace(/\s+/g, ' ') + .trim(); + + const cleaned = changes + .map((c) => ({ value: c.value, label: stripNewLine(sanitize(c.label)), pageNumber: c.pageNumber })) + .filter((c) => isMeaningful(c.label) && c.label.length > 0 && c.label.toLowerCase() !== newLineLabel.toLowerCase()); + const q = sanitize(query).toLowerCase(); + if (!q) return cleaned; + return cleaned.filter((c) => c.label.toLowerCase().includes(q)); + }, [changes, query, newLineLabel]); + + useEffect(() => { + // Build offsets for group headers whenever list changes while open + if (!isOpen) return; + const viewport = viewportRef.current; + if (!viewport) return; + const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[]; + groupOffsetsRef.current = headers.map((el) => { + const text = el.textContent || ''; + const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0; + return { top: el.offsetTop, page }; + }); + // Update sticky based on current scroll position + handleScrollPos({ x: 0, y: viewport.scrollTop }); + }, [normalizedChanges, isOpen]); + + const handleScrollPos = ({ y }: { x: number; y: number }) => { + const offsets = groupOffsetsRef.current; + if (offsets.length === 0) return; + // Find the last header whose top is <= scroll, so the next header replaces it + let low = 0; + let high = offsets.length - 1; + let idx = 0; + while (low <= high) { + const mid = (low + high) >> 1; + if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary + idx = mid; + low = mid + 1; + } else { + high = mid - 1; + } + } + const page = offsets[idx]?.page ?? offsets[0].page; + if (page !== stickyPage) setStickyPage(page); + }; + + return ( + { + const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber; + onNavigate(value, pn); + combobox.closeDropdown(); + }} + // Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly + > + +
combobox.toggleDropdown()} + > + {placeholder} + +
+
+ + + {/* Header sits outside scroll so it stays fixed at top */} +
+ setQuery(e.currentTarget.value)} + /> +
+ {/* Lazy render the scrollable content only when open */} + {isOpen && ( +
+ + {stickyPage != null && ( +
+ {t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage} + {renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && ( + — {t('compare.rendering.rendering', 'rendering')} + )} +
+ )} + + {normalizedChanges.length > 0 ? ( + (() => { + const nodes: React.ReactNode[] = []; + let lastPage: number | null = null; + for (const item of normalizedChanges) { + if (item.pageNumber && item.pageNumber !== lastPage) { + lastPage = item.pageNumber; + nodes.push( +
+ {t('compare.summary.pageLabel', 'Page')}{' '}{lastPage} + {renderedPageNumbers && !renderedPageNumbers.has(lastPage) && ( + — {t('compare.rendering.rendering', 'rendering')} + )} +
+ ); + } + nodes.push( + { + onNavigate(item.value, item.pageNumber); + combobox.closeDropdown(); + }} + > +
+ {item.label} +
+
+ ); + } + return nodes; + })() + ) : ( + {t('compare.dropdown.noResults', 'No changes found')} + )} +
+
+
+ )} +
+
+ ); +}; + +export default CompareNavigationDropdown; diff --git a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx new file mode 100644 index 0000000000..b7006ae3d2 --- /dev/null +++ b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -0,0 +1,421 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { Loader, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useIsMobile } from '@app/hooks/useIsMobile'; +import { + mapChangesForDropdown, + getFileFromSelection, + getStubFromSelection, + computeShowProgressBanner, + computeProgressPct, + computeCountsText, + computeMaxSharedPages, +} from '@app/components/tools/compare/compare'; +import { + CompareResultData, + CompareWorkbenchData, +} from '@app/types/compare'; +import { useFileContext } from '@app/contexts/file/fileHooks'; +import { useRightRailButtons } from '@app/hooks/useRightRailButtons'; +import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane'; +import { useComparePagePreviews } from '@app/components/tools/compare/hooks/useComparePagePreviews'; +import { useComparePanZoom } from '@app/components/tools/compare/hooks/useComparePanZoom'; +import { useCompareHighlights } from '@app/components/tools/compare/hooks/useCompareHighlights'; +import { useCompareChangeNavigation } from '@app/components/tools/compare/hooks/useCompareChangeNavigation'; +import '@app/components/tools/compare/compareView.css'; +import { useCompareRightRailButtons } from '@app/components/tools/compare/hooks/useCompareRightRailButtons'; +import { alert, updateToast, updateToastProgress, dismissToast } from '@app/components/toast'; +import type { ToastLocation } from '@app/components/toast/types'; + +interface CompareWorkbenchViewProps { + data: CompareWorkbenchData | null; +} + +// helpers moved to compare.ts + +const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { + const { t } = useTranslation(); + const prefersStacked = useIsMobile(); + const { selectors } = useFileContext(); + + const result: CompareResultData | null = data?.result ?? null; + const baseFileId = data?.baseFileId ?? null; + const comparisonFileId = data?.comparisonFileId ?? null; + const isOperationLoading = data?.isLoading ?? false; + + const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors); + const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors); + const baseStub = getStubFromSelection(baseFileId, selectors); + const comparisonStub = getStubFromSelection(comparisonFileId, selectors); + + const processedAt = result?.totals.processedAt ?? null; + + const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({ + file: baseFile, + enabled: Boolean(result && baseFile), + cacheKey: processedAt, + }); + + const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({ + file: comparisonFile, + enabled: Boolean(result && comparisonFile), + cacheKey: processedAt, + }); + + const { + layout, + toggleLayout, + baseScrollRef, + comparisonScrollRef, + handleScrollSync, + handleWheelZoom, + handleWheelOverscroll, + onTouchStart, + onTouchMove, + onTouchEnd, + isPanMode, + setIsPanMode, + baseZoom, + setBaseZoom, + comparisonZoom, + setComparisonZoom, + setPanToTopLeft, + centerPanForZoom, + clampPanForZoom, + clearScrollLinkDelta, + captureScrollLinkDelta, + setIsScrollLinked, + isScrollLinked, + zoomLimits, + } = useComparePanZoom({ + basePages, + comparisonPages, + prefersStacked, + }); + + const { + baseWordChanges, + comparisonWordChanges, + metaIndexToGroupId, + wordHighlightMaps, + getRowHeightPx, + } = useCompareHighlights(result, basePages, comparisonPages); + + const temporarilySuppressScrollLink = useCallback((fn: () => void, durationMs = 700) => { + const wasLinked = isScrollLinked; + if (wasLinked) setIsScrollLinked(false); + try { + fn(); + } finally { + window.setTimeout(() => { + if (wasLinked) { + // recapture anchors to keep panes aligned when relinking + captureScrollLinkDelta(); + setIsScrollLinked(true); + } + }, Math.max(200, durationMs)); + } + }, [isScrollLinked, setIsScrollLinked, captureScrollLinkDelta]); + + const handleChangeNavigation = useCompareChangeNavigation( + baseScrollRef, + comparisonScrollRef, + { temporarilySuppressScrollLink } + ); + + const processingMessage = t('compare.status.processing', 'Analyzing differences...'); + const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document'); + const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document'); + const pageLabel = t('compare.summary.pageLabel', 'Page'); + + // Always show the selected file names from the sidebar; they are known before diff results + const baseTitle = baseStub?.name || result?.base?.fileName || ''; + const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || ''; + + // During diff processing, show compact spinners in the dropdown badges + const baseDropdownPlaceholder = (isOperationLoading || !result) + ? ({t('compare.dropdown.deletionsLabel', 'Deletions')} ) + : t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length }); + const comparisonDropdownPlaceholder = (isOperationLoading || !result) + ? ({t('compare.dropdown.additionsLabel', 'Additions')} ) + : t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length }); + + const rightRailButtons = useCompareRightRailButtons({ + layout, + toggleLayout, + isPanMode, + setIsPanMode, + baseZoom, + comparisonZoom, + setBaseZoom, + setComparisonZoom, + setPanToTopLeft, + centerPanForZoom, + clampPanForZoom, + clearScrollLinkDelta, + captureScrollLinkDelta, + isScrollLinked, + setIsScrollLinked, + zoomLimits, + baseScrollRef, + comparisonScrollRef, + }); + + useRightRailButtons(rightRailButtons); + + // Rendering progress toast for very large PDFs + const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold + const totalsKnown = (baseTotal ?? 0) > 0 && (compTotal ?? 0) > 0; + const showProgressBanner = useMemo(() => ( + computeShowProgressBanner(totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading, LARGE_PAGE_THRESHOLD) + ), [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]); + + const progressPct = computeProgressPct(totalsKnown, baseTotal, compTotal, baseRendered, compRendered); + + const progressToastIdRef = useRef(null); + const completionTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (completionTimerRef.current != null) { + window.clearTimeout(completionTimerRef.current); + completionTimerRef.current = null; + } + if (progressToastIdRef.current) { + dismissToast(progressToastIdRef.current); + progressToastIdRef.current = null; + } + }; + }, []); + + const allDone = useMemo(() => { + const baseDone = (baseTotal || basePages.length) > 0 && baseRendered >= (baseTotal || basePages.length); + const compDone = (compTotal || comparisonPages.length) > 0 && compRendered >= (compTotal || comparisonPages.length); + return baseDone && compDone; + }, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]); + + // Drive toast lifecycle and progress updates + useEffect(() => { + // No toast needed + if (!showProgressBanner) { + if (progressToastIdRef.current) { + dismissToast(progressToastIdRef.current); + progressToastIdRef.current = null; + } + return; + } + + const countsText = computeCountsText( + baseRendered, + baseTotal, + basePages.length, + compRendered, + compTotal, + comparisonPages.length, + ); + if (!allDone) { + // Create toast if missing + if (!progressToastIdRef.current) { + const id = alert({ + alertType: 'neutral', + title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"), + body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`, + location: 'bottom-right' as ToastLocation, + isPersistentPopup: true, + durationMs: 0, + expandable: false, + progressBarPercentage: progressPct, + }); + progressToastIdRef.current = id; + } else { + updateToast(progressToastIdRef.current, { + title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"), + body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`, + location: 'bottom-right' as ToastLocation, + isPersistentPopup: true, + alertType: 'neutral', // ensure it stays neutral until completion + }); + updateToastProgress(progressToastIdRef.current, progressPct); + } + } else { + // Completed: update then auto-dismiss after 3s + if (progressToastIdRef.current) { + updateToast(progressToastIdRef.current, { + title: t('compare.rendering.complete', 'Page rendering complete'), + body: undefined, + isPersistentPopup: false, + durationMs: 3000, + }); + updateToastProgress(progressToastIdRef.current, 100); + if (completionTimerRef.current != null) window.clearTimeout(completionTimerRef.current); + completionTimerRef.current = window.setTimeout(() => { + if (progressToastIdRef.current) { + dismissToast(progressToastIdRef.current); + progressToastIdRef.current = null; + } + if (completionTimerRef.current != null) { + window.clearTimeout(completionTimerRef.current); + completionTimerRef.current = null; + } + }, 3000); + } + } + + return () => { + if (completionTimerRef.current != null) { + window.clearTimeout(completionTimerRef.current); + completionTimerRef.current = null; + } + }; + }, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]); + + // Shared page navigation state/input + const maxSharedPages = useMemo(() => ( + computeMaxSharedPages(baseTotal, compTotal, basePages.length, comparisonPages.length) + ), [baseTotal, compTotal, basePages.length, comparisonPages.length]); + + const [pageInputValue, setPageInputValue] = useState('1'); + const typingTimerRef = useRef(null); + const isTypingRef = useRef(false); + + // Clamp the displayed input if max changes smaller than current + useEffect(() => { + if (!pageInputValue) return; + const n = Math.max(1, parseInt(pageInputValue, 10) || 1); + if (maxSharedPages > 0 && n > maxSharedPages) { + setPageInputValue(String(maxSharedPages)); + } + }, [maxSharedPages]); + + const scrollBothToPage = useCallback((pageNum: number) => { + const scrollOne = (container: HTMLDivElement | null) => { + if (!container) return false; + const pageEl = container.querySelector(`.compare-diff-page[data-page-number="${pageNum}"]`) as HTMLElement | null; + if (!pageEl) return false; + const maxTop = Math.max(0, container.scrollHeight - container.clientHeight); + const desired = Math.max(0, Math.min(maxTop, pageEl.offsetTop - Math.round(container.clientHeight * 0.2))); + container.scrollTop = desired; + return true; + }; + + const hitBase = scrollOne(baseScrollRef.current); + const hitComp = scrollOne(comparisonScrollRef.current); + + // Warn if one or both pages are not yet rendered + const baseHas = basePages.some(p => p.pageNumber === pageNum); + const compHas = comparisonPages.some(p => p.pageNumber === pageNum); + if (!baseHas || !compHas) { + alert({ + alertType: 'warning', + title: t('compare.rendering.pageNotReadyTitle', 'Page not rendered yet'), + body: t('compare.rendering.pageNotReadyBody', 'Some pages are still rendering. Navigation will snap once they are ready.'), + location: 'bottom-right' as ToastLocation, + isPersistentPopup: false, + durationMs: 2500, + }); + } + + return hitBase || hitComp; + }, [basePages, comparisonPages, baseScrollRef, comparisonScrollRef, t]); + + const handleTypingChange = useCallback((next: string) => { + // Only digits; allow empty while editing + const digits = next.replace(/[^0-9]/g, ''); + if (digits.length === 0) { + setPageInputValue(''); + if (typingTimerRef.current != null) { + window.clearTimeout(typingTimerRef.current); + typingTimerRef.current = null; + } + return; + } + + const parsed = Math.max(1, parseInt(digits, 10)); + const capped = maxSharedPages > 0 ? Math.min(parsed, maxSharedPages) : parsed; + const display = String(capped); + setPageInputValue(display); + + isTypingRef.current = true; + if (typingTimerRef.current != null) window.clearTimeout(typingTimerRef.current); + typingTimerRef.current = window.setTimeout(() => { + isTypingRef.current = false; + scrollBothToPage(capped); + }, 300); + }, [maxSharedPages, scrollBothToPage]); + + return ( + + + +
+ handleChangeNavigation(value, 'base', pageNumber)} + isLoading={isOperationLoading || baseLoading} + processingMessage={processingMessage} + pages={basePages} + pairedPages={comparisonPages} + getRowHeightPx={getRowHeightPx} + wordHighlightMap={wordHighlightMaps.base} + metaIndexToGroupId={metaIndexToGroupId.base} + documentLabel={baseDocumentLabel} + pageLabel={pageLabel} + altLabel={baseDocumentLabel} + pageInputValue={pageInputValue} + onPageInputChange={handleTypingChange} + maxSharedPages={maxSharedPages} + /> + handleChangeNavigation(value, 'comparison', pageNumber)} + isLoading={isOperationLoading || comparisonLoading} + processingMessage={processingMessage} + pages={comparisonPages} + pairedPages={basePages} + getRowHeightPx={getRowHeightPx} + wordHighlightMap={wordHighlightMaps.comparison} + metaIndexToGroupId={metaIndexToGroupId.comparison} + documentLabel={comparisonDocumentLabel} + pageLabel={pageLabel} + altLabel={comparisonDocumentLabel} + pageInputValue={pageInputValue} + onPageInputChange={handleTypingChange} + maxSharedPages={maxSharedPages} + /> +
+
+
+ ); +}; + +export default CompareWorkbenchView; diff --git a/frontend/src/core/components/tools/compare/compare.ts b/frontend/src/core/components/tools/compare/compare.ts new file mode 100644 index 0000000000..e8b9fcbc92 --- /dev/null +++ b/frontend/src/core/components/tools/compare/compare.ts @@ -0,0 +1,239 @@ +import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare'; +import type { FileId } from '@app/types/file'; +import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext'; +import type { PagePreview } from '@app/types/compare'; + +/** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */ +export const toRgba = (hexColor: string, alpha: number): string => { + const hex = hexColor.replace('#', ''); + if (hex.length !== 6) return hexColor; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +/** Normalize rotation to [0, 360). */ +export const normalizeRotation = (deg: number | undefined | null): number => { + const n = ((deg ?? 0) % 360 + 360) % 360; + return n; +}; + +/** + * Merge overlapping or touching rectangles into larger non-overlapping blocks. + * Robust across rotations (vertical groups) and prevents dark spots from overlaps. + */ +export const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => { + if (rects.length === 0) return rects; + const EPS = 0.004; // small tolerance in normalized page coords + const sorted = rects + .slice() + .sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left)); + const merged: TokenBoundingBox[] = []; + + const overlapsOrTouches = (a: TokenBoundingBox, b: TokenBoundingBox) => { + const aR = a.left + a.width; + const aB = a.top + a.height; + const bR = b.left + b.width; + const bB = b.top + b.height; + return !(b.left > aR + EPS || bR < a.left - EPS || b.top > aB + EPS || bB < a.top - EPS); + }; + + for (const r of sorted) { + let mergedIntoExisting = false; + for (let i = 0; i < merged.length; i += 1) { + const m = merged[i]; + if (overlapsOrTouches(m, r)) { + const left = Math.min(m.left, r.left); + const top = Math.min(m.top, r.top); + const right = Math.max(m.left + m.width, r.left + r.width); + const bottom = Math.max(m.top + m.height, r.top + r.height); + merged[i] = { + left, + top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; + mergedIntoExisting = true; + break; + } + } + if (!mergedIntoExisting) merged.push({ ...r }); + } + return merged; +}; + +/** Group word rectangles by change id using metaIndexToGroupId. */ +export const groupWordRects = ( + wordRects: WordHighlightEntry[], + metaIndexToGroupId: Map, + pane: 'base' | 'comparison' +): Map => { + const groupedRects = new Map(); + for (const { rect, metaIndex } of wordRects) { + const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`; + const current = groupedRects.get(id) ?? []; + current.push(rect); + groupedRects.set(id, current); + } + return groupedRects; +}; + +/** Compute derived layout metrics for a page render, given environment and zoom. */ +export const computePageLayoutMetrics = (args: { + page: PagePreview; + peerPage?: PagePreview | null; + layout: 'side-by-side' | 'stacked'; + isMobileViewport: boolean; + scrollRefWidth: number | null; + viewportWidth: number; + zoom: number; + offsetPixels: number; // highlight offset in px relative to original page height +}) => { + const { page, peerPage, layout, isMobileViewport, scrollRefWidth, viewportWidth, zoom, offsetPixels } = args; + const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height; + const fit = targetHeight / page.height; + const highlightOffset = offsetPixels / page.height; + const rotationNorm = normalizeRotation(page.rotation); + const isPortrait = rotationNorm === 0 || rotationNorm === 180; + const isStackedPortrait = layout === 'stacked' && isPortrait; + + const containerW = scrollRefWidth ?? viewportWidth; + const stackedWidth = isMobileViewport + ? Math.max(320, Math.round(containerW)) + : Math.max(320, Math.round(viewportWidth * 0.5)); + const stackedHeight = Math.round(stackedWidth * 1.4142); + + const baseWidth = isStackedPortrait ? stackedWidth : Math.round(page.width * fit); + const baseHeight = isStackedPortrait ? stackedHeight : Math.round(targetHeight); + const containerMaxW = scrollRefWidth ?? viewportWidth; + + // Container-first zooming with a stable baseline: + // Treat zoom=1 as "fit to available width" for the page's base size so + // the initial render is fully visible and centered (no cropping), regardless + // of rotation or pane/container width. When zoom < 1, shrink the container; + // when zoom > 1, keep the container at fit width and scale inner content. + const MIN_CONTAINER_WIDTH = 120; + const minScaleByWidth = MIN_CONTAINER_WIDTH / Math.max(1, baseWidth); + const fitScaleByContainer = containerMaxW / Math.max(1, baseWidth); + // Effective baseline scale used at zoom=1 (ensures at least the min width) + const baselineContainerScale = Math.max(minScaleByWidth, fitScaleByContainer); + // Lower bound the zoom so interactions remain stable + const desiredZoom = Math.max(0.1, zoom); + + let containerScale: number; + let innerScale: number; + if (desiredZoom >= 1) { + // At or above baseline: keep container at fit width and scale inner content + containerScale = baselineContainerScale; + innerScale = +Math.max(0.1, desiredZoom).toFixed(4); + } else { + // Below baseline: shrink container proportionally, do not upscale inner + const scaled = baselineContainerScale * desiredZoom; + // Never smaller than minimum readable width + containerScale = Math.max(minScaleByWidth, scaled); + innerScale = 1; + } + + const containerWidth = Math.max( + MIN_CONTAINER_WIDTH, + Math.min(containerMaxW, Math.round(baseWidth * containerScale)) + ); + const containerHeight = Math.round(baseHeight * (containerWidth / Math.max(1, baseWidth))); + + return { + targetHeight, + fit, + highlightOffset, + rotationNorm, + isPortrait, + isStackedPortrait, + baseWidth, + baseHeight, + containerMaxW, + containerWidth, + containerHeight, + innerScale, + }; +}; + +/** Map changes to dropdown options tuple. */ +export const mapChangesForDropdown = ( + changes: Array<{ value: string; label: string; pageNumber: number }> +) => changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber })); + +/** File selection helpers */ +export const getFileFromSelection = ( + explicit: StirlingFile | null | undefined, + fileId: FileId | null, + selectors: { getFile: (id: FileId) => StirlingFile | undefined | null } +): StirlingFile | null => { + if (explicit) return explicit; + if (!fileId) return null; + return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null; +}; + +export const getStubFromSelection = ( + fileId: FileId | null, + selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined } +): StirlingFileStub | null => { + if (!fileId) return null; + const stub = selectors.getStirlingFileStub(fileId); + return stub ?? null; +}; + +/** Progress banner computations */ +export const computeShowProgressBanner = ( + totalsKnown: boolean, + baseTotal: number | null | undefined, + compTotal: number | null | undefined, + baseLoading: boolean, + compLoading: boolean, + threshold: number = 400 +): boolean => { + if (!totalsKnown) return false; + const totals = [baseTotal ?? 0, compTotal ?? 0]; + return Math.max(...totals) >= threshold && (baseLoading || compLoading); +}; + +export const computeProgressPct = ( + totalsKnown: boolean, + baseTotal: number | null | undefined, + compTotal: number | null | undefined, + baseRendered: number, + compRendered: number +): number => { + const totalCombined = totalsKnown ? ((baseTotal ?? 0) + (compTotal ?? 0)) : 0; + const renderedCombined = baseRendered + compRendered; + return totalsKnown && totalCombined > 0 + ? Math.min(100, Math.round((renderedCombined / totalCombined) * 100)) + : 0; +}; + +export const computeCountsText = ( + baseRendered: number, + baseTotal: number | null | undefined, + baseLength: number, + compRendered: number, + compTotal: number | null | undefined, + compLength: number +): string => { + const baseTotalShown = baseTotal || baseLength; + const compTotalShown = compTotal || compLength; + return `${baseRendered}/${baseTotalShown} • ${compRendered}/${compTotalShown}`; +}; + +export const computeMaxSharedPages = ( + baseTotal: number | null | undefined, + compTotal: number | null | undefined, + baseLen: number, + compLen: number +): number => { + const baseMax = baseTotal || baseLen || 0; + const compMax = compTotal || compLen || 0; + const minKnown = Math.min(baseMax || Infinity, compMax || Infinity); + if (!Number.isFinite(minKnown)) return 0; + return Math.max(0, minKnown); +}; + + diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css new file mode 100644 index 0000000000..78e562859f --- /dev/null +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -0,0 +1,515 @@ +.compare-dropdown-scrollwrap { + position: relative; +} + +.compare-dropdown-sticky { + position: sticky; + z-index: 2; + background: var(--compare-page-label-bg); + color: var(--compare-page-label-fg); + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--border-subtle); + pointer-events: none; +} + +[data-mantine-color-scheme="dark"] .compare-dropdown-sticky { + background: var(--compare-page-label-bg); + color: var(--compare-page-label-fg); + border-bottom: 1px solid var(--border-default); +} +.compare-workbench { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1rem; + height: 100%; + min-height: 0; + /* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */ + min-width: 0; +} + +.compare-workbench__content { + flex: 1; + min-height: 0; +} + +.compare-pane { + display: flex; + flex-direction: column; + height: 100%; +} + +.compare-pane__scroll { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} + +.compare-pane__content { + position: relative; +} + +.compare-workbench__mode { + align-self: center; +} + +.compare-workbench__columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + align-items: start; + width: 100%; + min-width: 0; + height: 100%; + min-height: 0; + grid-auto-rows: 1fr; +} + +/* Stacked mode: two rows with synchronized scroll panes */ +.compare-workbench__columns--stacked { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; +} + +.compare-workbench__columns > div { + /* Critical for responsive flex children inside non-wrapping layouts */ + min-height: 0; + min-width: 0; +} + +.compare-legend { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; +} + +/* Sticky header styling overrides */ +.compare-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--bg-toolbar); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border-default); + padding: 0.5rem; + margin: -0.5rem -0.5rem 0.5rem -0.5rem; +} + +/* Dropdown badge-like style - only style the dropdowns, not titles */ +.compare-changes-select { + background: var(--spdf-compare-removed-badge-bg) !important; + color: var(--spdf-compare-removed-badge-fg) !important; + border: none !important; + border-radius: 8px !important; + font-weight: 500 !important; + cursor: pointer; + min-width: 200px; + padding: 0.375rem 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.875rem !important; + box-sizing: border-box; +} + +.compare-changes-select__placeholder { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.compare-changes-select__placeholder .mantine-Loader-root { + display: inline-flex; + margin: 0 0.125rem; +} + +.compare-changes-select--comparison { + background: var(--spdf-compare-added-badge-bg) !important; + color: var(--spdf-compare-added-badge-fg) !important; + border: none !important; + border-radius: 8px !important; + font-weight: 500 !important; +} + +/* Wider dropdown menu for long block text */ +.compare-changes-dropdown { + min-width: 520px !important; + max-width: 70vw !important; + padding: 0 !important; + overflow: hidden; /* prevent inner elements from overflowing rounded edges */ + border-radius: 8px !important; /* match dropdown container radius */ +} + +/* Ensure options text uses full width inside wider dropdown */ +.compare-dropdown-option__text { + max-width: 100%; +} + +/* Remove default padding inside Mantine options list so sticky header touches edges */ +.compare-changes-dropdown .mantine-Combobox-options { + padding: 0 !important; +} + +.compare-dropdown-scrollwrap { margin: 0; } + +/* Ensure search field sits flush and does not overflow */ +.compare-changes-dropdown .mantine-Combobox-search { + box-sizing: border-box; + width: 100% !important; + margin: 0 !important; + border-top-left-radius: 8px !important; + border-top-right-radius: 8px !important; +} + +/* Style the dropdown container */ +.compare-changes-select .mantine-Combobox-dropdown { + border: 1px solid var(--border-subtle) !important; + border-radius: 8px !important; + box-shadow: var(--shadow-md) !important; + background-color: var(--bg-surface) !important; +} + +.compare-changes-select--comparison .mantine-Combobox-dropdown { + border: 1px solid var(--border-subtle) !important; + border-radius: 8px !important; + box-shadow: var(--shadow-md) !important; + background-color: var(--bg-surface) !important; +} + +/* Custom scrollbar for ScrollArea */ +.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar { + width: 6px !important; +} + +.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track { + background: var(--bg-muted) !important; + border-radius: 3px !important; +} + +.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb { + background: var(--border-strong) !important; + border-radius: 3px !important; +} + +.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover { + background: var(--text-muted) !important; +} + +.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar { + width: 6px !important; +} + +.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track { + background: var(--bg-muted) !important; + border-radius: 3px !important; +} + +.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb { + background: var(--border-strong) !important; + border-radius: 3px !important; +} + +.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover { + background: var(--text-muted) !important; +} + +/* Style the dropdown options */ +.compare-changes-select .mantine-Combobox-option { + font-size: 0.875rem !important; + padding: 8px 12px !important; +} + +.compare-changes-select--comparison .mantine-Combobox-option { + font-size: 0.875rem !important; + padding: 8px 12px !important; +} + +.compare-changes-select .mantine-Combobox-option:hover { + background-color: var(--spdf-compare-removed-badge-bg) !important; +} + +.compare-changes-select--comparison .mantine-Combobox-option:hover { + background-color: var(--spdf-compare-added-badge-bg) !important; +} + +/* Style the search input */ +.compare-changes-select .mantine-Combobox-search { + font-size: 0.875rem !important; + padding: 8px 12px !important; + border-bottom: 1px solid var(--border-subtle) !important; +} + +.compare-changes-select--comparison .mantine-Combobox-search { + font-size: 0.875rem !important; + padding: 8px 12px !important; + border-bottom: 1px solid var(--border-subtle) !important; +} + +.compare-changes-select .mantine-Combobox-search::placeholder { + color: var(--text-muted) !important; +} + +.compare-changes-select--comparison .mantine-Combobox-search::placeholder { + color: var(--text-muted) !important; +} + +/* Style the chevron - ensure proper coloring */ +.compare-changes-select .mantine-Combobox-chevron, +.compare-changes-select--comparison .mantine-Combobox-chevron { + color: inherit !important; + margin-left: 0.5rem; +} + +/* Flash/pulse highlight for navigated change */ +@keyframes compare-flash { + 0% { + outline: 4px solid rgba(255, 235, 59, 0.0); + box-shadow: 0 0 0 rgba(255, 235, 59, 0.0); + background-color: rgba(255, 235, 59, 0.2) !important; + } + 25% { + outline: 4px solid rgba(255, 235, 59, 1.0); + box-shadow: 0 0 20px rgba(255, 235, 59, 0.8); + background-color: rgba(255, 235, 59, 0.4) !important; + } + 50% { + outline: 4px solid rgba(255, 235, 59, 1.0); + box-shadow: 0 0 30px rgba(255, 235, 59, 0.9); + background-color: rgba(255, 235, 59, 0.5) !important; + } + 75% { + outline: 4px solid rgba(255, 235, 59, 0.8); + box-shadow: 0 0 15px rgba(255, 235, 59, 0.6); + background-color: rgba(255, 235, 59, 0.3) !important; + } + 100% { + outline: 4px solid rgba(255, 235, 59, 0.0); + box-shadow: 0 0 0 rgba(255, 235, 59, 0.0); + background-color: rgba(255, 235, 59, 0.0) !important; + } +} + +.compare-diff-highlight--flash { + animation: compare-flash 1.5s ease-in-out 1; + z-index: 1000; + position: relative; + /* Bonus: temporarily override red/green to yellow during flash for clarity */ + background-color: rgba(255, 235, 59, 0.5) !important; +} + +/* Union overlay for group flash */ +.compare-diff-flash-overlay { + animation: compare-flash 1.5s ease-in-out 1; + z-index: 999; + background-color: rgba(255, 235, 59, 0.4); + pointer-events: none; + border-radius: 2px; +} + +.compare-legend__item { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.75rem; + color: var(--mantine-color-dimmed); +} + +.compare-legend__swatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 999px; + border: 1px solid rgba(15, 23, 42, 0.15); +} + +.compare-summary__stats { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.compare-summary__stat-card { + flex: 1; + min-width: 12rem; +} + +.compare-summary__segment { + border: 1px solid var(--mantine-color-gray-3); + border-radius: 0.5rem; + padding: 0.75rem; + background-color: var(--mantine-color-gray-0); +} + +.compare-diff-page { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.compare-diff-page__canvas { + position: relative; + border: 1px solid var(--border-strong); + border-radius: 0.75rem; + overflow: hidden; + background-color: var(--bg-surface); + width: 100%; +} + +/* Center canvas in stacked portrait mode (width/height set inline) */ +.compare-diff-page__canvas[style*="margin-left: auto"] { + display: block; +} + +.compare-diff-page__canvas--zoom { + overflow: hidden; + max-width: 100%; +} + +.compare-diff-page__inner { + position: relative; + width: 100%; + margin-left: auto; + margin-right: auto; + max-width: 100%; + background-color: #fff; /* ensure stable white backing during load */ + border: 1px solid var(--border-subtle); + will-change: transform; +} + +.compare-diff-page__image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Centered per-page title wrapper */ +.compare-page-title { + text-align: center; + margin-left: auto; + margin-right: auto; +} + +.compare-page-title .mantine-Text-root { + display: inline-block; + padding: 2px 8px; + border-radius: 8px; + background-color: var(--compare-page-label-bg); + color: var(--compare-page-label-fg); +} + +/* Overlay loader to avoid flash when image not yet loaded */ +.compare-page-loader-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + background-color: rgba(255, 255, 255, 0.9); +} + +.compare-diff-highlight { + position: absolute; + pointer-events: none; + mix-blend-mode: normal; +} + +/* Compare dropdown option formatting (page + clamped text) */ +.compare-dropdown-option { + display: flex; + flex-direction: column; + gap: 0.125rem; +} +.compare-dropdown-option__page { + font-size: 0.7rem; + color: var(--text-muted); +} +.compare-dropdown-option__text { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + line-clamp: 3; +} + +/* Non-sticky in-flow group headers; sticky handled by floating header */ +.compare-dropdown-group { + position: static; + background: var(--compare-page-label-bg); + color: var(--compare-page-label-fg); + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-bottom: 1px solid var(--border-subtle); +} + +.compare-dropdown-group.compare-dropdown-group--hidden { + height: 0; + padding: 0; + margin: 0; + border: 0; + overflow: hidden; +} + +[data-mantine-color-scheme="dark"] .compare-dropdown-group { + background: var(--compare-page-label-bg); + color: var(--compare-page-label-fg); + border-bottom: 1px solid var(--border-default); +} + +/* Light grey rendering flag next to page labels in the dropdown */ +.compare-dropdown-rendering-flag { + color: var(--text-muted); + margin-left: 0.25rem; +} + +/* Inline paragraph highlights in summary */ +.compare-inline { + border-radius: 0.2rem; + padding: 0.05rem 0.15rem; +} +.compare-inline--removed { + background-color: var(--spdf-compare-inline-removed-bg); +} +.compare-inline--added { + background-color: var(--spdf-compare-inline-added-bg); +} + +.compare-pane-header { + position: sticky; + top: 0; + z-index: 2; + background: var(--bg-background); + padding: 0.25rem 0; +} + +/* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */ +.compare-tool__thumbnail { + width: 4rem; + height: 5.25rem; + flex-shrink: 0; +} + +.compare-tool__details { + flex: 1; + min-width: 0; +} + +/* Mobile: remove side margins and let canvases take full width inside column */ +@media (max-width: 768px) { + .compare-workbench__columns { + grid-template-columns: 1fr; + } + .compare-diff-page__canvas.compare-diff-page__canvas--zoom { + width: 100% !important; + margin-left: 0 !important; + margin-right: 0 !important; + } + .compare-diff-page__inner { + margin-left: 0 !important; + margin-right: 0 !important; + } +} diff --git a/frontend/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts b/frontend/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts new file mode 100644 index 0000000000..f137581e88 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts @@ -0,0 +1,247 @@ +import { RefObject, useCallback } from 'react'; + +type Pane = 'base' | 'comparison'; + +type SuppressOptions = { + temporarilySuppressScrollLink?: (fn: () => void, durationMs?: number) => void; +}; + +export const useCompareChangeNavigation = ( + baseScrollRef: RefObject, + comparisonScrollRef: RefObject, + options?: SuppressOptions, +) => { + return useCallback( + (changeValue: string, pane: Pane, pageNumber?: number) => { + const suppress = (fn: () => T) => { + if (options?.temporarilySuppressScrollLink) { + options.temporarilySuppressScrollLink(fn, 700); + } else { + fn(); + } + }; + + const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef; + const container = targetRef.current; + if (!container) { + return; + } + + const findNodes = (): HTMLElement[] => { + return Array.from( + container.querySelectorAll(`[data-change-id="${changeValue}"]`) + ) as HTMLElement[]; + }; + + const scrollToPageIfNeeded = () => { + if (!pageNumber) return false; + const pageEl = container.querySelector( + `.compare-diff-page[data-page-number="${pageNumber}"]` + ) as HTMLElement | null; + if (!pageEl) return false; + const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2); + suppress(() => { + container.scrollTo({ top: Math.max(0, top), behavior: 'auto' }); + }); + return true; + }; + + const scrollPeerPageIfPossible = () => { + if (!pageNumber) return; + const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef; + const peer = peerRef.current; + if (!peer) return; + const peerPageEl = peer.querySelector( + `.compare-diff-page[data-page-number="${pageNumber}"]` + ) as HTMLElement | null; + if (!peerPageEl) return; + const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight); + const top = Math.max( + 0, + Math.min( + peerMaxTop, + peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2) + ) + ); + suppress(() => { + peer.scrollTo({ top, behavior: 'auto' }); + }); + }; + + const proceedWithNodes = (nodes: HTMLElement[]) => { + if (nodes.length === 0) return; + + // Prefer a percent-in-page based vertical scroll, which is resilient to transforms. + const anchor = nodes[0]; + const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null; + const inner = anchor.closest('.compare-diff-page__inner') as HTMLElement | null; + const topPercent = parseFloat((anchor as HTMLElement).style.top || '0'); + if (pageEl && inner && !Number.isNaN(topPercent)) { + const innerRect = inner.getBoundingClientRect(); + const innerHeight = Math.max(1, innerRect.height); + const absoluteTopInPage = (topPercent / 100) * innerHeight; + const maxTop = Math.max(0, container.scrollHeight - container.clientHeight); + const desiredTop = Math.max( + 0, + Math.min(maxTop, pageEl.offsetTop + absoluteTopInPage - container.clientHeight / 2) + ); + suppress(() => { + container.scrollTo({ top: desiredTop, behavior: 'auto' }); + }); + } else { + // Fallback to bounding-rect based centering if percent approach is unavailable. + const containerRect = container.getBoundingClientRect(); + let minTop = Number.POSITIVE_INFINITY; + let minLeft = Number.POSITIVE_INFINITY; + let maxBottom = Number.NEGATIVE_INFINITY; + let maxRight = Number.NEGATIVE_INFINITY; + + nodes.forEach((element) => { + const rect = element.getBoundingClientRect(); + minTop = Math.min(minTop, rect.top); + minLeft = Math.min(minLeft, rect.left); + maxBottom = Math.max(maxBottom, rect.bottom); + maxRight = Math.max(maxRight, rect.right); + }); + + const boxHeight = Math.max(1, maxBottom - minTop); + const boxWidth = Math.max(1, maxRight - minLeft); + const absoluteTop = minTop - containerRect.top + container.scrollTop; + const absoluteLeft = minLeft - containerRect.left + container.scrollLeft; + const maxTop = Math.max(0, container.scrollHeight - container.clientHeight); + const desiredTop = Math.max(0, Math.min(maxTop, absoluteTop - (container.clientHeight - boxHeight) / 2)); + const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2); + + suppress(() => { + container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'auto' }); + }); + } + + // Also scroll the peer container to the corresponding location in the + // other PDF (same page and approximate vertical position within page), + // not just the same list/scroll position. + const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef; + const peer = peerRef.current; + if (peer) { + // Use the first node as the anchor + const anchor = nodes[0]; + const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null; + const pageNumAttr = pageEl?.getAttribute('data-page-number'); + const topPercent = parseFloat((anchor as HTMLElement).style.top || '0'); + if (pageNumAttr) { + const peerPageEl = peer.querySelector( + `.compare-diff-page[data-page-number="${pageNumAttr}"]` + ) as HTMLElement | null; + const peerInner = peerPageEl?.querySelector('.compare-diff-page__inner') as HTMLElement | null; + if (peerPageEl && peerInner) { + const innerRect = peerInner.getBoundingClientRect(); + const innerHeight = Math.max(1, innerRect.height); + const absoluteTopInPage = (topPercent / 100) * innerHeight; + const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight); + const peerDesiredTop = Math.max( + 0, + Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2) + ); + suppress(() => { + peer.scrollTo({ top: peerDesiredTop, behavior: 'auto' }); + }); + } else if (peerPageEl) { + // Fallback: Scroll to page top (clamped) + const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight); + const top = Math.max(0, Math.min(peerMaxTop, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2))); + suppress(() => { + peer.scrollTo({ top, behavior: 'auto' }); + }); + } + } + } + + const groupsByInner = new Map(); + nodes.forEach((element) => { + const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null; + if (!inner) return; + const list = groupsByInner.get(inner) ?? []; + list.push(element); + groupsByInner.set(inner, list); + }); + + groupsByInner.forEach((elements, inner) => { + let minL = 100; + let minT = 100; + let maxR = 0; + let maxB = 0; + elements.forEach((element) => { + const leftPercent = parseFloat(element.style.left) || 0; + const topPercent = parseFloat(element.style.top) || 0; + const widthPercent = parseFloat(element.style.width) || 0; + const heightPercent = parseFloat(element.style.height) || 0; + minL = Math.min(minL, leftPercent); + minT = Math.min(minT, topPercent); + maxR = Math.max(maxR, leftPercent + widthPercent); + maxB = Math.max(maxB, topPercent + heightPercent); + }); + const overlay = document.createElement('span'); + overlay.className = 'compare-diff-flash-overlay'; + overlay.style.position = 'absolute'; + overlay.style.left = `${minL}%`; + overlay.style.top = `${minT}%`; + overlay.style.width = `${Math.max(0.1, maxR - minL)}%`; + overlay.style.height = `${Math.max(0.1, maxB - minT)}%`; + inner.appendChild(overlay); + window.setTimeout(() => overlay.remove(), 1600); + }); + + nodes.forEach((element) => { + element.classList.remove('compare-diff-highlight--flash'); + }); + void container.clientWidth; // Force reflow + nodes.forEach((element) => { + element.classList.add('compare-diff-highlight--flash'); + window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600); + }); + }; + + const nodes = findNodes(); + if (nodes.length > 0) { + proceedWithNodes(nodes); + return; + } + + // Page-level fallback immediately so the user sees something happen + const scrolledPage = scrollToPageIfNeeded(); + if (scrolledPage) { + scrollPeerPageIfPossible(); + } else { + // Even if the page element is not present yet, try to nudge peer pane + scrollPeerPageIfPossible(); + } + + // Wait for highlights to mount (pages/images render progressively) + let settled = false; + const observer = new MutationObserver(() => { + if (settled) return; + const n = findNodes(); + if (n.length > 0) { + settled = true; + observer.disconnect(); + proceedWithNodes(n); + } + }); + try { + observer.observe(container, { childList: true, subtree: true }); + } catch { + // noop + } + // Safety timeout to stop waiting after a while + window.setTimeout(() => { + if (settled) return; + settled = true; + observer.disconnect(); + // We already scrolled to the page above; nothing else to do. + }, 5000); + }, + [baseScrollRef, comparisonScrollRef] + ); +}; + +export type UseCompareChangeNavigationReturn = ReturnType; diff --git a/frontend/src/core/components/tools/compare/hooks/useCompareHighlights.ts b/frontend/src/core/components/tools/compare/hooks/useCompareHighlights.ts new file mode 100644 index 0000000000..f06b6b499e --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useCompareHighlights.ts @@ -0,0 +1,144 @@ +import { useCallback, useMemo } from 'react'; +import type { + CompareFilteredTokenInfo, + WordHighlightEntry, + CompareResultData, + CompareChangeOption, + PagePreview, +} from '@app/types/compare'; + +interface MetaGroupMap { + base: Map; + comparison: Map; +} + +interface WordHighlightMaps { + base: Map; + comparison: Map; +} + +export interface UseCompareHighlightsResult { + baseWordChanges: CompareChangeOption[]; + comparisonWordChanges: CompareChangeOption[]; + metaIndexToGroupId: MetaGroupMap; + wordHighlightMaps: WordHighlightMaps; + getRowHeightPx: (pageNumber: number) => number; +} + +const buildWordChanges = ( + tokens: CompareFilteredTokenInfo[], + metaIndexToGroupId: Map, + groupPrefix: string +): CompareChangeOption[] => { + metaIndexToGroupId.clear(); + if (!tokens.length) return []; + + const items: CompareChangeOption[] = []; + let currentRun: CompareFilteredTokenInfo[] = []; + + const flushRun = () => { + if (currentRun.length === 0) return; + const label = currentRun.map((token) => token.token).join(' ').trim(); + if (label.length === 0) { + currentRun = []; + return; + } + const first = currentRun[0]; + const last = currentRun[currentRun.length - 1]; + const groupId = `${groupPrefix}-t${first.metaIndex}-t${last.metaIndex}`; + currentRun.forEach((token) => { + metaIndexToGroupId.set(token.metaIndex, groupId); + }); + const pageNumber = first.page ?? last.page ?? 1; + items.push({ value: groupId, label, pageNumber }); + currentRun = []; + }; + + for (const token of tokens) { + if (token.hasHighlight && token.bbox) { + currentRun.push(token); + } else { + flushRun(); + } + } + flushRun(); + + return items; +}; + +const buildHighlightMap = ( + tokens: CompareFilteredTokenInfo[] +): Map => { + const map = new Map(); + for (const token of tokens) { + if (!token.hasHighlight || !token.bbox || token.page == null) continue; + const list = map.get(token.page) ?? []; + list.push({ rect: token.bbox, metaIndex: token.metaIndex }); + map.set(token.page, list); + } + return map; +}; + +export const useCompareHighlights = ( + result: CompareResultData | null, + basePages: PagePreview[], + comparisonPages: PagePreview[], +): UseCompareHighlightsResult => { + const baseMetaIndexToGroupId = useMemo(() => new Map(), []); + const comparisonMetaIndexToGroupId = useMemo(() => new Map(), []); + + const baseWordChanges = useMemo(() => { + if (!result) return []; + return buildWordChanges( + result.filteredTokenData.base, + baseMetaIndexToGroupId, + 'base-group' + ); + }, [baseMetaIndexToGroupId, result]); + + const comparisonWordChanges = useMemo(() => { + if (!result) return []; + return buildWordChanges( + result.filteredTokenData.comparison, + comparisonMetaIndexToGroupId, + 'comparison-group' + ); + }, [comparisonMetaIndexToGroupId, result]); + + const wordHighlightMaps = useMemo(() => { + if (!result) { + return { + base: new Map(), + comparison: new Map(), + }; + } + + return { + base: buildHighlightMap(result.filteredTokenData.base), + comparison: buildHighlightMap(result.filteredTokenData.comparison), + }; + }, [result]); + + const getRowHeightPx = useCallback( + (pageNumber: number) => { + const basePage = basePages.find((page) => page.pageNumber === pageNumber); + const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber); + const baseHeight = basePage ? basePage.height : 0; + const comparisonHeight = comparisonPage ? comparisonPage.height : 0; + const rowHeight = Math.max(baseHeight, comparisonHeight); + return Math.round(rowHeight); + }, + [basePages, comparisonPages] + ); + + return { + baseWordChanges, + comparisonWordChanges, + metaIndexToGroupId: { + base: baseMetaIndexToGroupId, + comparison: comparisonMetaIndexToGroupId, + }, + wordHighlightMaps, + getRowHeightPx, + }; +}; diff --git a/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts new file mode 100644 index 0000000000..19e4c93632 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts @@ -0,0 +1,253 @@ +import { useEffect, useRef, useState } from 'react'; +import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; +import type { PagePreview } from '@app/types/compare'; + +const DISPLAY_SCALE = 1; + +const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1); + +// Observable preview cache so rendering progress can resume across remounts and view switches +type CacheEntry = { pages: PagePreview[]; total: number; subscribers: Set<() => void> }; +const previewCache: Map = new Map(); +const latestVersionMap: Map = new Map(); + +const getOrCreateEntry = (key: string): CacheEntry => { + let entry = previewCache.get(key); + if (!entry) { + entry = { pages: [], total: 0, subscribers: new Set() }; + previewCache.set(key, entry); + } + return entry; +}; + +const notify = (entry: CacheEntry) => { + entry.subscribers.forEach((fn) => { + try { fn(); } catch { /* no-op */ } + }); +}; + +const subscribe = (key: string, fn: () => void): (() => void) => { + const entry = getOrCreateEntry(key); + entry.subscribers.add(fn); + return () => entry.subscribers.delete(fn); +}; + +const appendBatchToCache = (key: string, batch: PagePreview[], provisionalTotal?: number) => { + const entry = getOrCreateEntry(key); + const next = entry.pages.slice(); + for (const p of batch) { + const idx = next.findIndex((x) => x.pageNumber > p.pageNumber); + if (idx === -1) next.push(p); else next.splice(idx, 0, p); + } + entry.pages = next; + if (typeof provisionalTotal === 'number' && entry.total === 0) entry.total = provisionalTotal; + notify(entry); +}; + +const setTotalInCache = (key: string, total: number) => { + const entry = getOrCreateEntry(key); + entry.total = total; + notify(entry); +}; + +const replacePagesInCache = (key: string, pages: PagePreview[], total?: number) => { + const entry = getOrCreateEntry(key); + entry.pages = pages.slice(); + if (typeof total === 'number') entry.total = total; + notify(entry); +}; + + +const renderPdfDocumentToImages = async ( + file: File, + onBatch?: (previews: PagePreview[]) => void, + batchSize: number = 12, + onInitTotal?: (totalPages: number) => void, + startAtPage: number = 1, + shouldAbort?: () => boolean, +): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { + disableAutoFetch: true, + disableStream: true, + }); + + try { + const previews: PagePreview[] = []; + const dpr = getDevicePixelRatio(); + const renderScale = Math.max(2, Math.min(3, dpr * 2)); + onInitTotal?.(pdf.numPages); + + let batch: PagePreview[] = []; + const shouldStop = () => Boolean(shouldAbort?.()); + + for (let pageNumber = Math.max(1, startAtPage); pageNumber <= pdf.numPages; pageNumber += 1) { + if (shouldStop()) break; + const page = await pdf.getPage(pageNumber); + const displayViewport = page.getViewport({ scale: DISPLAY_SCALE }); + const renderViewport = page.getViewport({ scale: renderScale }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = Math.round(renderViewport.width); + canvas.height = Math.round(renderViewport.height); + + if (!context) { + page.cleanup(); + continue; + } + + try { + await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise; + if (shouldStop()) break; + + const preview: PagePreview = { + pageNumber, + width: Math.round(displayViewport.width), + height: Math.round(displayViewport.height), + rotation: (page.rotate || 0) % 360, + url: canvas.toDataURL(), + }; + previews.push(preview); + if (onBatch) { + batch.push(preview); + if (batch.length >= batchSize) { + onBatch(batch); + batch = []; + } + } + } finally { + page.cleanup(); + canvas.width = 0; + canvas.height = 0; + } + + if (shouldStop()) break; + } + + if (onBatch && batch.length > 0) onBatch(batch); + return previews; + } finally { + pdfWorkerManager.destroyDocument(pdf); + } +}; + +interface UseComparePagePreviewsOptions { + file: File | null; + enabled: boolean; + cacheKey: number | null; +} + +export const useComparePagePreviews = ({ + file, + enabled, + cacheKey, +}: UseComparePagePreviewsOptions) => { + const [pages, setPages] = useState([]); + const [loading, setLoading] = useState(false); + const [totalPages, setTotalPages] = useState(0); + const inFlightRef = useRef(0); + + useEffect(() => { + let cancelled = false; + + if (!file || !enabled) { + setPages([]); + setLoading(false); + setTotalPages(0); + return () => { + cancelled = true; + }; + } + + const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`; + const refreshVersion = Symbol(key); + latestVersionMap.set(key, refreshVersion); + const entry = getOrCreateEntry(key); + const cachedTotal = entry.total ?? (entry.pages.length ?? 0); + let lastKnownTotal = cachedTotal; + const isFullyCached = Boolean(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal); + + if (entry.pages.length > 0) { + const nextPages = entry.pages.slice(); + setPages(nextPages); + setTotalPages(cachedTotal); + } else { + setTotalPages(0); + } + + setLoading(!isFullyCached); + + const unsubscribe = subscribe(key, () => { + const e = getOrCreateEntry(key); + setPages(e.pages.slice()); + setTotalPages(e.total); + const done = e.pages.length > 0 && e.total > 0 && e.pages.length >= e.total; + setLoading(!done); + }); + + if (isFullyCached) { + return () => { + cancelled = true; + }; + } + + const render = async () => { + setLoading(true); + try { + inFlightRef.current += 1; + const current = inFlightRef.current; + const startAt = (entry?.pages?.length ?? 0) + 1; + const previews = await renderPdfDocumentToImages( + file, + (batch) => { + if (cancelled || current !== inFlightRef.current) return; + appendBatchToCache(key, batch, lastKnownTotal || cachedTotal); + }, + 16, + (total) => { + if (!cancelled && current === inFlightRef.current) { + lastKnownTotal = total; + setTotalInCache(key, total); + } + }, + startAt, + () => cancelled || current !== inFlightRef.current + ); + if (!cancelled && current === inFlightRef.current) { + const stillLatest = latestVersionMap.get(key) === refreshVersion; + if (!stillLatest) { + return; + } + const cacheEntry = getOrCreateEntry(key); + const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length; + lastKnownTotal = finalTotal; + const cachePages = cacheEntry.pages ?? []; + const preferPreviews = previews.length > cachePages.length; + const finalPages = preferPreviews ? previews.slice() : cachePages.slice(); + replacePagesInCache(key, finalPages, finalTotal); + } + } catch (error) { + console.error('[compare] failed to render document preview', error); + if (!cancelled) { + setPages([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + render(); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, [file, enabled, cacheKey]); + + return { pages, loading, totalPages, renderedPages: pages.length }; +}; + +export type UseComparePagePreviewsReturn = ReturnType; diff --git a/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts new file mode 100644 index 0000000000..df5a275b97 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts @@ -0,0 +1,895 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + MouseEvent as ReactMouseEvent, + TouchEvent as ReactTouchEvent, + WheelEvent as ReactWheelEvent, +} from 'react'; +import type { + PagePreview, + ComparePane as Pane, + PanState, + ScrollLinkDelta, + ScrollLinkAnchors, + PanDragState, + PinchState, + UseComparePanZoomOptions, + UseComparePanZoomReturn, +} from '@app/types/compare'; + +const ZOOM_MIN = 0.5; +const ZOOM_MAX = 100000; +const ZOOM_STEP = 0.1; + +// Default structural adjustments applied to each rendered page row. These are +// refined at runtime via DOM measurements once the panes have mounted. +const DEFAULT_ROW_STRUCTURAL_EXTRA = 32; +const DEFAULT_ROW_GAP = 8; + +// (Interfaces moved to @app/types/compare) + +export const useComparePanZoom = ({ + basePages, + comparisonPages, + prefersStacked, +}: UseComparePanZoomOptions): UseComparePanZoomReturn => { + const baseScrollRef = useRef(null); + const comparisonScrollRef = useRef(null); + const isSyncingRef = useRef(false); + const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false }); + const scrollLinkDeltaRef = useRef({ vertical: 0, horizontal: 0 }); + const scrollLinkAnchorsRef = useRef({ + deltaPixelsBaseToComp: 0, + deltaPixelsCompToBase: 0, + }); + const [isScrollLinked, setIsScrollLinked] = useState(true); + const [isPanMode, setIsPanMode] = useState(false); + const panDragRef = useRef({ + active: false, + source: null, + startX: 0, + startY: 0, + startPanX: 0, + startPanY: 0, + targetStartPanX: 0, + targetStartPanY: 0, + }); + const lastActivePaneRef = useRef('base'); + const [baseZoom, setBaseZoom] = useState(1); + const [comparisonZoom, setComparisonZoom] = useState(1); + const [basePan, setBasePan] = useState({ x: 0, y: 0 }); + const [comparisonPan, setComparisonPan] = useState({ x: 0, y: 0 }); + const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 }); + const pinchRef = useRef({ active: false, pane: null, startDistance: 0, startZoom: 1 }); + const edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 }); + const [rowStructuralExtraPx, setRowStructuralExtraPx] = useState(DEFAULT_ROW_STRUCTURAL_EXTRA); + const [rowGapPx, setRowGapPx] = useState(DEFAULT_ROW_GAP); + + const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side'); + const setLayout = useCallback((next: 'side-by-side' | 'stacked') => { + setLayoutState(next); + }, []); + const toggleLayout = useCallback(() => { + setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side')); + }, []); + + useEffect(() => { + setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev)); + }, [prefersStacked]); + + const getPagesForPane = useCallback( + (pane: Pane) => (pane === 'base' ? basePages : comparisonPages), + [basePages, comparisonPages] + ); + + // rAF-coalesced follower scroll writes + const syncRafRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null }); + const desiredTopRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null }); + + const canonicalLayout = useMemo(() => { + const baseMap = new Map(); + const compMap = new Map(); + for (const page of basePages) baseMap.set(page.pageNumber, page); + for (const page of comparisonPages) compMap.set(page.pageNumber, page); + + const allPageNumbers = Array.from( + new Set([ + ...basePages.map(p => p.pageNumber), + ...comparisonPages.map(p => p.pageNumber), + ]) + ).sort((a, b) => a - b); + + const rows = allPageNumbers.map(pageNumber => { + const basePage = baseMap.get(pageNumber) ?? null; + const compPage = compMap.get(pageNumber) ?? null; + const canonicalHeight = Math.max(basePage?.height ?? 0, compPage?.height ?? 0); + return { + pageNumber, + canonicalHeight, + hasBase: Boolean(basePage), + hasComparison: Boolean(compPage), + }; + }); + + const totalCanonicalHeight = rows.reduce((sum, row) => sum + Math.round(row.canonicalHeight), 0); + return { rows, totalCanonicalHeight }; + }, [basePages, comparisonPages]); + + // Measure structural padding (labels, internal gaps) and the inter-row gap + // so the scroll mapper can account for real DOM layout instead of relying on + // bare page image heights. + useEffect(() => { + if (typeof window === 'undefined') return; + if (canonicalLayout.rows.length === 0) return; + + const raf = window.requestAnimationFrame(() => { + const sourceContent = + baseScrollRef.current?.querySelector('.compare-pane__content') ?? + comparisonScrollRef.current?.querySelector('.compare-pane__content'); + if (!sourceContent) return; + + const style = window.getComputedStyle(sourceContent); + const gapStr = style.rowGap || style.gap; + const parsedGap = gapStr ? Number.parseFloat(gapStr) : Number.NaN; + const measuredGap = Number.isNaN(parsedGap) ? rowGapPx : Math.max(0, Math.round(parsedGap)); + if (measuredGap !== rowGapPx) { + setRowGapPx(measuredGap); + } + + const totalGap = Math.max(0, canonicalLayout.rows.length - 1) * measuredGap; + const contentHeight = Math.round(sourceContent.scrollHeight); + const available = contentHeight - totalGap - canonicalLayout.totalCanonicalHeight; + const candidate = canonicalLayout.rows.length > 0 + ? Math.max(0, Math.round(available / canonicalLayout.rows.length)) + : 0; + + if (Math.abs(candidate - rowStructuralExtraPx) >= 1) { + setRowStructuralExtraPx(candidate); + } + }); + return () => window.cancelAnimationFrame(raf); + }, [canonicalLayout, rowGapPx, rowStructuralExtraPx, layout]); + + // Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height + const rowHeights = useMemo(() => { + const totalRows = canonicalLayout.rows.length; + const base: number[] = []; + const comp: number[] = []; + for (let index = 0; index < totalRows; index += 1) { + const row = canonicalLayout.rows[index]; + const canonicalHeight = Math.round(row.canonicalHeight); + const structuralHeight = Math.max(0, Math.round(canonicalHeight + rowStructuralExtraPx)); + const includeGap = index < totalRows - 1 ? rowGapPx : 0; + const totalHeight = structuralHeight + includeGap; + if (row.hasBase) base.push(totalHeight); + else if (row.hasComparison) base.push(totalHeight); + if (row.hasComparison) comp.push(totalHeight); + else if (row.hasBase) comp.push(totalHeight); + } + + const prefix = (arr: number[]) => { + const out: number[] = new Array(arr.length + 1); + out[0] = 0; + for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i]; + return out; + }; + + return { + base, + comp, + basePrefix: prefix(base), + compPrefix: prefix(comp), + }; + }, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]); + + const mapScrollTopBetweenPanes = useCallback( + (sourceTop: number, sourceIsBase: boolean): number => { + const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp; + const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base; + const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix; + const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix; + + if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop; + + // Clamp to valid range + const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1); + const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop))); + + // Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1] + let lo = 0; + let hi = srcHeights.length - 1; + while (lo < hi) { + const mid = Math.floor((lo + hi + 1) / 2); + if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1; + } + const i = lo; + const within = top - srcPrefix[i]; + const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0; + + const j = Math.min(i, dstHeights.length - 1); + const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1); + return dstTop; + }, + [rowHeights] + ); + + const getMaxCanvasSize = useCallback( + (pane: Pane) => { + const pages = getPagesForPane(pane); + const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base'); + let maxW = 0; + let maxH = 0; + for (const page of pages) { + const peer = peers.find(p => p.pageNumber === page.pageNumber); + const targetHeight = peer ? Math.max(page.height, peer.height) : page.height; + const fit = targetHeight / page.height; + const width = Math.round(page.width * fit); + const height = Math.round(targetHeight); + if (width > maxW) maxW = width; + if (height > maxH) maxH = height; + } + return { maxW, maxH }; + }, + [getPagesForPane] + ); + + const getPanBounds = useCallback( + (pane: Pane, zoomOverride?: number) => { + const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current; + const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null; + let canvasW: number | null = null; + let canvasH: number | null = null; + if (canvasEl) { + const rect = canvasEl.getBoundingClientRect(); + canvasW = Math.max(0, Math.round(rect.width)); + canvasH = Math.max(0, Math.round(rect.height)); + } + + const fallback = getMaxCanvasSize(pane); + const W = canvasW ?? fallback.maxW; + const H = canvasH ?? fallback.maxH; + const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom; + const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1)); + const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1)); + return { maxX: extraX, maxY: extraY }; + }, + [baseZoom, comparisonZoom, getMaxCanvasSize] + ); + + const getPaneRotation = useCallback( + (pane: Pane) => { + const pages = getPagesForPane(pane); + const rotation = pages[0]?.rotation ?? 0; + const normalized = ((rotation % 360) + 360) % 360; + return normalized as 0 | 90 | 180 | 270 | number; + }, + [getPagesForPane] + ); + + const mapPanBetweenOrientations = useCallback( + (source: Pane, target: Pane, sourcePan: PanState) => { + const sourceRotation = getPaneRotation(source); + const targetRotation = getPaneRotation(target); + const sourceBounds = getPanBounds(source); + const targetBounds = getPanBounds(target); + + const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1; + const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1; + + const applyRotation = (nx: number, ny: number, rotation: number) => { + const r = ((rotation % 360) + 360) % 360; + if (r === 0) return { nx, ny }; + if (r === 90) return { nx: ny, ny: -nx }; + if (r === 180) return { nx: -nx, ny: -ny }; + if (r === 270) return { nx: -ny, ny: nx }; + return { nx, ny }; + }; + + const logical = applyRotation(sx, sy, sourceRotation); + const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation); + + const targetNormX = (targetCentered.nx + 1) / 2; + const targetNormY = (targetCentered.ny + 1) / 2; + + const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX)); + const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY)); + return { x: targetX, y: targetY }; + }, + [getPaneRotation, getPanBounds] + ); + + const centerPanForZoom = useCallback( + (pane: Pane, zoomValue: number) => { + const bounds = getPanBounds(pane, zoomValue); + const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) }; + if (pane === 'base') { + setBasePan(center); + } else { + setComparisonPan(center); + } + }, + [getPanBounds] + ); + + const setPanToTopLeft = useCallback((pane: Pane) => { + if (pane === 'base') { + setBasePan({ x: 0, y: 0 }); + } else { + setComparisonPan({ x: 0, y: 0 }); + } + }, []); + + const clampPanForZoom = useCallback( + (pane: Pane, zoomValue: number) => { + const bounds = getPanBounds(pane, zoomValue); + const current = pane === 'base' ? basePan : comparisonPan; + const clamped = { + x: Math.max(0, Math.min(bounds.maxX, current.x)), + y: Math.max(0, Math.min(bounds.maxY, current.y)), + }; + if (pane === 'base') { + setBasePan(clamped); + } else { + setComparisonPan(clamped); + } + }, + [basePan, comparisonPan, getPanBounds] + ); + + const handleScrollSync = useCallback( + (source: HTMLDivElement | null, target: HTMLDivElement | null) => { + if (panDragRef.current.active) return; + if (!source || !target || isSyncingRef.current || !isScrollLinked) { + return; + } + + const sourceIsBase = source === baseScrollRef.current; + const sourceKey = sourceIsBase ? 'base' : 'comparison'; + + // Only sync if this scroll was initiated by the user (wheel/scrollbar/keyboard), + // not by our own programmatic scrolls. + if (!userScrollRef.current[sourceKey]) { + return; + } + + lastActivePaneRef.current = sourceIsBase ? 'base' : 'comparison'; + + const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); + const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase); + + // Use pixel anchors captured at link time to preserve offset + const deltaPx = sourceIsBase + ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp + : scrollLinkAnchorsRef.current.deltaPixelsCompToBase; + + const rawDesired = mappedTop + deltaPx; + const desiredTop = Math.max(0, Math.min(targetVerticalRange, rawDesired)); + + // If the mapping requests a position beyond target bounds and the target is already + // at that bound, skip writing to avoid any subtle feedback that could impede + // continued scrolling in the source pane. + const atTopBound = desiredTop === 0 && target.scrollTop === 0 && rawDesired < 0; + const atBottomBound = desiredTop === targetVerticalRange && target.scrollTop === targetVerticalRange && rawDesired > targetVerticalRange; + if (atTopBound || atBottomBound) { + return; + } + + const targetIsBase = target === baseScrollRef.current; + const key = targetIsBase ? 'base' : 'comparison'; + + desiredTopRef.current[key] = desiredTop; + if (syncRafRef.current[key] == null) { + syncRafRef.current[key] = requestAnimationFrame(() => { + const el = targetIsBase ? baseScrollRef.current : comparisonScrollRef.current; + const top = desiredTopRef.current[key] ?? 0; + if (el) { + isSyncingRef.current = true; + el.scrollTop = top; + } + syncRafRef.current[key] = null; + requestAnimationFrame(() => { + isSyncingRef.current = false; + }); + }); + } + }, + [isScrollLinked, mapScrollTopBetweenPanes] + ); + + // Track user-initiated scroll state per pane + useEffect(() => { + const baseEl = baseScrollRef.current; + const compEl = comparisonScrollRef.current; + if (!baseEl || !compEl) return; + + const onUserScrollStartBase = () => { userScrollRef.current.base = true; }; + const onUserScrollStartComp = () => { userScrollRef.current.comparison = true; }; + const onUserScrollEndBase = () => { userScrollRef.current.base = false; }; + const onUserScrollEndComp = () => { userScrollRef.current.comparison = false; }; + + const addUserListeners = (el: HTMLDivElement, onStart: () => void, onEnd: () => void) => { + el.addEventListener('wheel', onStart, { passive: true }); + el.addEventListener('mousedown', onStart, { passive: true }); + el.addEventListener('touchstart', onStart, { passive: true }); + // Heuristic: clear the flag shortly after scroll events settle + let timeout: number | null = null; + const onScroll = () => { + // Ignore programmatic scrolls to avoid feedback loops and unnecessary syncing work + if (isSyncingRef.current) return; + onStart(); + if (timeout != null) window.clearTimeout(timeout); + timeout = window.setTimeout(onEnd, 120); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('wheel', onStart as any); + el.removeEventListener('mousedown', onStart as any); + el.removeEventListener('touchstart', onStart as any); + el.removeEventListener('scroll', onScroll as any); + if (timeout != null) window.clearTimeout(timeout); + }; + }; + + const cleanupBase = addUserListeners(baseEl, onUserScrollStartBase, onUserScrollEndBase); + const cleanupComp = addUserListeners(compEl, onUserScrollStartComp, onUserScrollEndComp); + return () => { cleanupBase(); cleanupComp(); }; + }, []); + + // Helpers for clearer pan-edge overscroll behavior + const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => { + if (rawY < 0) return rawY; // negative -> scroll up + if (rawY > maxY) return rawY - maxY; // positive -> scroll down + return 0; + }, []); + + const normalizeApplyCandidate = useCallback((overflowY: number): number => { + const DEADZONE = 32; // pixels + if (overflowY < -DEADZONE) return overflowY + DEADZONE; + if (overflowY > DEADZONE) return overflowY - DEADZONE; + return 0; + }, []); + + const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => { + const STEP = 48; // pixels per incremental scroll + const key = isBase ? 'base' : 'comparison'; + const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key]; + const magnitude = Math.abs(deltaSinceLast); + if (magnitude < STEP) return; + + const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP; + edgeOverscrollRef.current[key] += stepDelta; + + const prevTop = container.scrollTop; + const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta)); + if (nextTop === prevTop) return; + + container.scrollTop = nextTop; + if (isScrollLinked) { + const sourceIsBase = isBase; + const target = isBase ? comparisonScrollRef.current : baseScrollRef.current; + if (target) { + const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); + const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase); + const deltaPx = sourceIsBase + ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp + : scrollLinkAnchorsRef.current.deltaPixelsCompToBase; + const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx)); + target.scrollTop = desiredTop; + } + } + }, [isScrollLinked, mapScrollTopBetweenPanes]); + + const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => { + const container = isBase ? baseScrollRef.current : comparisonScrollRef.current; + if (!container) return; + const overflowY = getVerticalOverflow(rawY, boundsMaxY); + const applyCandidate = normalizeApplyCandidate(overflowY); + if (applyCandidate !== 0) { + applyIncrementalScroll(container, isBase, applyCandidate); + } else { + // Reset accumulator when back within deadzone + edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0; + } + }, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]); + + const beginPan = useCallback( + (pane: Pane, event: ReactMouseEvent) => { + if (!isPanMode) return; + const zoom = pane === 'base' ? baseZoom : comparisonZoom; + if (zoom <= 1) return; + const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current; + if (!container) return; + + const targetEl = event.target as HTMLElement | null; + const isOnImage = !!targetEl?.closest('.compare-diff-page__inner'); + if (!isOnImage) return; + + event.preventDefault(); + panDragRef.current = { + active: true, + source: pane, + startX: event.clientX, + startY: event.clientY, + startPanX: pane === 'base' ? basePan.x : comparisonPan.x, + startPanY: pane === 'base' ? basePan.y : comparisonPan.y, + targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x, + targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y, + }; + edgeOverscrollRef.current[pane] = 0; + lastActivePaneRef.current = pane; + (container as HTMLDivElement).style.cursor = 'grabbing'; + }, + [isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan] + ); + + const continuePan = useCallback( + (event: ReactMouseEvent) => { + if (!isPanMode) return; + const drag = panDragRef.current; + if (!drag.active || !drag.source) return; + + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + + const isBase = drag.source === 'base'; + const bounds = getPanBounds(drag.source); + const rawX = drag.startPanX - dx; + const rawY = drag.startPanY - dy; + const desired = { + x: Math.max(0, Math.min(bounds.maxX, rawX)), + y: Math.max(0, Math.min(bounds.maxY, rawY)), + }; + + // On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps) + handlePanEdgeOverscroll(rawY, bounds.maxY, isBase); + + if (isBase) { + setBasePan(desired); + } else { + setComparisonPan(desired); + } + }, + [getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations] + ); + + const endPan = useCallback(() => { + const drag = panDragRef.current; + if (!drag.active) return; + const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current; + if (sourceEl) { + const zoom = drag.source === 'base' ? baseZoom : comparisonZoom; + (sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : ''; + } + panDragRef.current.active = false; + panDragRef.current.source = null; + }, [baseZoom, comparisonZoom, isPanMode]); + + const handleWheelZoom = useCallback( + (pane: Pane, event: ReactWheelEvent) => { + if (!event.ctrlKey) return; + event.preventDefault(); + const key = pane === 'base' ? 'base' : 'comparison'; + const accum = wheelZoomAccumRef.current; + const threshold = 180; + accum[key] += event.deltaY; + const steps = Math.trunc(Math.abs(accum[key]) / threshold); + if (steps <= 0) return; + const direction = accum[key] > 0 ? -1 : 1; + accum[key] = accum[key] % threshold; + const applySteps = (zoom: number) => { + let next = zoom; + for (let i = 0; i < steps; i += 1) { + next = direction > 0 + ? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2)) + : Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2)); + } + return next; + }; + if (pane === 'base') { + const prev = baseZoom; + const next = applySteps(prev); + setBaseZoom(next); + if (next < prev) { + centerPanForZoom('base', next); + } else { + clampPanForZoom('base', next); + } + } else { + const prev = comparisonZoom; + const next = applySteps(prev); + setComparisonZoom(next); + if (next < prev) { + centerPanForZoom('comparison', next); + } else { + clampPanForZoom('comparison', next); + } + } + }, + [baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom] + ); + + // When the source pane hits its scroll limit but the peer still has room, + // propagate the wheel delta to the peer so it continues following. + const handleWheelOverscroll = useCallback( + (pane: Pane, event: ReactWheelEvent) => { + if (event.ctrlKey) return; // handled by zoom handler + if (!isScrollLinked) return; + + const source = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current; + const target = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current; + if (!source || !target) return; + + const deltaY = event.deltaY; + if (deltaY === 0) return; + + const sourceMax = Math.max(0, source.scrollHeight - source.clientHeight); + const nextSource = Math.max(0, Math.min(sourceMax, source.scrollTop + deltaY)); + + // If the source can scroll, let the normal scroll event drive syncing + if (nextSource !== source.scrollTop) return; + + // Source is at a bound; push mapped delta into the target + const sourceIsBase = pane === 'base'; + + // Map the desired new source position (scrollTop + deltaY) into target space + const mappedBefore = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase); + const mappedAfter = mapScrollTopBetweenPanes(source.scrollTop + deltaY, sourceIsBase); + const mappedDelta = mappedAfter - mappedBefore; + + // Include the pixel anchor captured when linking + const deltaPx = sourceIsBase + ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp + : scrollLinkAnchorsRef.current.deltaPixelsCompToBase; + + const targetMax = Math.max(0, target.scrollHeight - target.clientHeight); + const desired = Math.max(0, Math.min(targetMax, target.scrollTop + (mappedDelta || deltaY))); + + if (desired !== target.scrollTop) { + isSyncingRef.current = true; + // Adjust relative to mapped space to keep the anchor consistent + const anchored = Math.max(0, Math.min(targetMax, mappedBefore + deltaPx + (mappedDelta || deltaY))); + target.scrollTop = anchored; + requestAnimationFrame(() => { + isSyncingRef.current = false; + }); + event.preventDefault(); + } + }, + [isScrollLinked, mapScrollTopBetweenPanes] + ); + + const onTouchStart = useCallback( + (pane: Pane, event: ReactTouchEvent) => { + if (event.touches.length === 2) { + const [t1, t2] = [event.touches[0], event.touches[1]]; + const dx = t1.clientX - t2.clientX; + const dy = t1.clientY - t2.clientY; + pinchRef.current = { + active: true, + pane, + startDistance: Math.hypot(dx, dy), + startZoom: pane === 'base' ? baseZoom : comparisonZoom, + }; + event.preventDefault(); + } else if (event.touches.length === 1) { + if (!isPanMode) return; + const zoom = pane === 'base' ? baseZoom : comparisonZoom; + if (zoom <= 1) return; + const targetEl = event.target as HTMLElement | null; + const isOnImage = !!targetEl?.closest('.compare-diff-page__inner'); + if (!isOnImage) return; + const touch = event.touches[0]; + panDragRef.current = { + active: true, + source: pane, + startX: touch.clientX, + startY: touch.clientY, + startPanX: pane === 'base' ? basePan.x : comparisonPan.x, + startPanY: pane === 'base' ? basePan.y : comparisonPan.y, + targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x, + targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y, + }; + edgeOverscrollRef.current[pane] = 0; + event.preventDefault(); + } + }, + [basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode] + ); + + const onTouchMove = useCallback( + (event: ReactTouchEvent) => { + if (pinchRef.current.active && event.touches.length === 2) { + const [t1, t2] = [event.touches[0], event.touches[1]]; + const dx = t1.clientX - t2.clientX; + const dy = t1.clientY - t2.clientY; + const distance = Math.hypot(dx, dy); + const scale = distance / Math.max(1, pinchRef.current.startDistance); + const dampened = 1 + (scale - 1) * 0.6; + const pane = pinchRef.current.pane!; + const startZoom = pinchRef.current.startZoom; + const previousZoom = pane === 'base' ? baseZoom : comparisonZoom; + const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2))); + if (pane === 'base') { + setBaseZoom(nextZoom); + if (nextZoom < previousZoom) { + centerPanForZoom('base', nextZoom); + } else if (nextZoom > previousZoom) { + clampPanForZoom('base', nextZoom); + } + } else { + setComparisonZoom(nextZoom); + if (nextZoom < previousZoom) { + centerPanForZoom('comparison', nextZoom); + } else if (nextZoom > previousZoom) { + clampPanForZoom('comparison', nextZoom); + } + } + event.preventDefault(); + return; + } + + if (panDragRef.current.active && event.touches.length === 1) { + const touch = event.touches[0]; + const dx = touch.clientX - panDragRef.current.startX; + const dy = touch.clientY - panDragRef.current.startY; + const isBase = panDragRef.current.source === 'base'; + const bounds = getPanBounds(panDragRef.current.source!); + const rawX = panDragRef.current.startPanX - dx; + const rawY = panDragRef.current.startPanY - dy; + const desired = { + x: Math.max(0, Math.min(bounds.maxX, rawX)), + y: Math.max(0, Math.min(bounds.maxY, rawY)), + }; + + handlePanEdgeOverscroll(rawY, bounds.maxY, isBase); + if (isBase) { + setBasePan(desired); + } else { + setComparisonPan(desired); + } + event.preventDefault(); + } + }, + [baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations] + ); + + const onTouchEnd = useCallback(() => { + pinchRef.current.active = false; + pinchRef.current.pane = null; + panDragRef.current.active = false; + }, []); + + // Auto-toggle Pan Mode based on zoom level + useEffect(() => { + const shouldPan = baseZoom > 1 || comparisonZoom > 1; + if (isPanMode !== shouldPan) setIsPanMode(shouldPan); + }, [baseZoom, comparisonZoom, isPanMode]); + + // When new pages render and extend scrollHeight, re-apply the mapping so + // the follower continues tracking instead of getting stuck at its prior max. + useEffect(() => { + if (!isScrollLinked) return; + const sourceIsBase = lastActivePaneRef.current === 'base'; + const source = sourceIsBase ? baseScrollRef.current : comparisonScrollRef.current; + const target = sourceIsBase ? comparisonScrollRef.current : baseScrollRef.current; + if (!source || !target) return; + + const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase); + const deltaPx = sourceIsBase + ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp + : scrollLinkAnchorsRef.current.deltaPixelsCompToBase; + const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); + const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx)); + + if (Math.abs(target.scrollTop - desiredTop) > 1) { + isSyncingRef.current = true; + target.scrollTop = desiredTop; + requestAnimationFrame(() => { isSyncingRef.current = false; }); + } + }, [basePages.length, comparisonPages.length, isScrollLinked, mapScrollTopBetweenPanes]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (isScrollLinked) return; + const target = event.target as HTMLElement | null; + const tag = (target?.tagName || '').toLowerCase(); + const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true'); + if (isEditable) return; + + const baseEl = baseScrollRef.current; + const compEl = comparisonScrollRef.current; + if (!baseEl || !compEl) return; + + const STEP = 80; + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const delta = event.key === 'ArrowDown' ? STEP : -STEP; + isSyncingRef.current = true; + baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight)); + compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight)); + requestAnimationFrame(() => { + isSyncingRef.current = false; + }); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isScrollLinked]); + + const captureScrollLinkDelta = useCallback(() => { + const baseEl = baseScrollRef.current; + const compEl = comparisonScrollRef.current; + if (!baseEl || !compEl) { + scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 }; + scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 }; + return; + } + const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight); + const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight); + const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth); + const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth); + + const baseV = baseEl.scrollTop / baseVMax; + const compV = compEl.scrollTop / compVMax; + const baseH = baseEl.scrollLeft / baseHMax; + const compH = compEl.scrollLeft / compHMax; + + scrollLinkDeltaRef.current = { + vertical: compV - baseV, + horizontal: compH - baseH, + }; + + // Capture pixel anchors in mapped space + const mappedBaseToComp = mapScrollTopBetweenPanes(baseEl.scrollTop, true); + const mappedCompToBase = mapScrollTopBetweenPanes(compEl.scrollTop, false); + scrollLinkAnchorsRef.current = { + deltaPixelsBaseToComp: compEl.scrollTop - mappedBaseToComp, + deltaPixelsCompToBase: baseEl.scrollTop - mappedCompToBase, + }; + }, [mapScrollTopBetweenPanes]); + + const clearScrollLinkDelta = useCallback(() => { + scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 }; + scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 }; + }, []); + + const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []); + + return { + layout, + setLayout, + toggleLayout, + baseScrollRef, + comparisonScrollRef, + isScrollLinked, + setIsScrollLinked, + captureScrollLinkDelta, + clearScrollLinkDelta, + isPanMode, + setIsPanMode, + baseZoom, + setBaseZoom, + comparisonZoom, + setComparisonZoom, + basePan, + comparisonPan, + setPanToTopLeft, + centerPanForZoom, + clampPanForZoom, + handleScrollSync, + beginPan, + continuePan, + endPan, + handleWheelZoom, + handleWheelOverscroll, + onTouchStart, + onTouchMove, + onTouchEnd, + zoomLimits, + }; +}; diff --git a/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx b/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx new file mode 100644 index 0000000000..f87e43ae1c --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx @@ -0,0 +1,191 @@ +import { useMemo } from 'react'; +import type React from 'react'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { alert } from '@app/components/toast'; +import type { ToastLocation } from '@app/components/toast/types'; +import type { RightRailButtonWithAction } from '@app/hooks/useRightRailButtons'; +import { useIsMobile } from '@app/hooks/useIsMobile'; + +type Pane = 'base' | 'comparison'; + +export interface UseCompareRightRailButtonsOptions { + layout: 'side-by-side' | 'stacked'; + toggleLayout: () => void; + isPanMode: boolean; + setIsPanMode: (value: boolean) => void; + baseZoom: number; + comparisonZoom: number; + setBaseZoom: (value: number) => void; + setComparisonZoom: (value: number) => void; + setPanToTopLeft: (pane: Pane) => void; + centerPanForZoom: (pane: Pane, zoom: number) => void; + clampPanForZoom: (pane: Pane, zoom: number) => void; + clearScrollLinkDelta: () => void; + captureScrollLinkDelta: () => void; + isScrollLinked: boolean; + setIsScrollLinked: (value: boolean) => void; + zoomLimits: { min: number; max: number; step: number }; + baseScrollRef?: React.RefObject; + comparisonScrollRef?: React.RefObject; +} + +export const useCompareRightRailButtons = ({ + layout, + toggleLayout, + isPanMode, + setIsPanMode, + baseZoom, + comparisonZoom, + setBaseZoom, + setComparisonZoom, + setPanToTopLeft, + centerPanForZoom, + clampPanForZoom, + clearScrollLinkDelta, + captureScrollLinkDelta, + isScrollLinked, + setIsScrollLinked, + zoomLimits, + baseScrollRef, + comparisonScrollRef, +}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => { + const { t } = useTranslation(); + const isMobile = useIsMobile(); + + return useMemo(() => [ + { + id: 'compare-toggle-layout', + icon: ( + + ), + tooltip: layout === 'side-by-side' + ? t('compare.actions.stackVertically', 'Stack vertically') + : t('compare.actions.placeSideBySide', 'Place side by side'), + ariaLabel: layout === 'side-by-side' + ? t('compare.actions.stackVertically', 'Stack vertically') + : t('compare.actions.placeSideBySide', 'Place side by side'), + section: 'top', + order: 10, + onClick: toggleLayout, + }, + { + id: 'compare-zoom-out', + icon: , + tooltip: t('compare.actions.zoomOut', 'Zoom out'), + ariaLabel: t('compare.actions.zoomOut', 'Zoom out'), + section: 'top', + order: 13, + onClick: () => { + const { min, step } = zoomLimits; + const nextBase = Math.max(min, +(baseZoom - step).toFixed(2)); + const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2)); + setBaseZoom(nextBase); + setComparisonZoom(nextComparison); + centerPanForZoom('base', nextBase); + centerPanForZoom('comparison', nextComparison); + }, + }, + { + id: 'compare-zoom-in', + icon: , + tooltip: t('compare.actions.zoomIn', 'Zoom in'), + ariaLabel: t('compare.actions.zoomIn', 'Zoom in'), + section: 'top', + order: 14, + onClick: () => { + const { max, step } = zoomLimits; + const nextBase = Math.min(max, +(baseZoom + step).toFixed(2)); + const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2)); + setBaseZoom(nextBase); + setComparisonZoom(nextComparison); + clampPanForZoom('base', nextBase); + clampPanForZoom('comparison', nextComparison); + }, + }, + { + id: 'compare-reset-view', + icon: , + tooltip: t('compare.actions.resetView', 'Reset zoom and pan'), + ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'), + section: 'top', + order: 14.5, + disabled: baseZoom === 1 && comparisonZoom === 1, + onClick: () => { + setBaseZoom(1); + setComparisonZoom(1); + setPanToTopLeft('base'); + setPanToTopLeft('comparison'); + clearScrollLinkDelta(); + // Reset scrollTop for both panes to realign view + if (baseScrollRef?.current) { + baseScrollRef.current.scrollTop = 0; + } + if (comparisonScrollRef?.current) { + comparisonScrollRef.current.scrollTop = 0; + } + }, + }, + { + id: 'compare-toggle-scroll-link', + icon: ( + + ), + tooltip: isScrollLinked + ? t('compare.actions.unlinkScroll', 'Unlink scroll') + : t('compare.actions.linkScroll', 'Link scroll'), + ariaLabel: isScrollLinked + ? t('compare.actions.unlinkScroll', 'Unlink scroll') + : t('compare.actions.linkScroll', 'Link scroll'), + section: 'top', + order: 15, + onClick: () => { + const next = !isScrollLinked; + if (next) { + captureScrollLinkDelta(); + } else { + if (!isMobile) { + alert({ + alertType: 'neutral', + title: t('compare.toasts.unlinkedTitle', 'Independent scroll enabled'), + body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes when unlinked is off.'), + durationMs: 5000, + location: 'bottom-center' as ToastLocation, + expandable: false, + }); + } + } + setIsScrollLinked(next); + }, + }, + ], [ + layout, + toggleLayout, + isPanMode, + setIsPanMode, + baseZoom, + comparisonZoom, + setBaseZoom, + setComparisonZoom, + centerPanForZoom, + clampPanForZoom, + setPanToTopLeft, + clearScrollLinkDelta, + captureScrollLinkDelta, + isScrollLinked, + setIsScrollLinked, + zoomLimits, + t, + isMobile, + ]); +}; + +export type UseCompareRightRailButtonsReturn = ReturnType; diff --git a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx index 0c39da957a..b463b6bb6e 100644 --- a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx @@ -87,7 +87,7 @@ const FileStatusIndicator = ({ openFilesModal()} + onClick={() => openFilesModal({})} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} > @@ -122,7 +122,7 @@ const FileStatusIndicator = ({ {getPlaceholder() + " "} openFilesModal()} + onClick={() => openFilesModal({})} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} > diff --git a/frontend/src/core/components/tools/shared/createToolFlow.tsx b/frontend/src/core/components/tools/shared/createToolFlow.tsx index 3ff8a3cc97..1a98d1cb3d 100644 --- a/frontend/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/core/components/tools/shared/createToolFlow.tsx @@ -86,7 +86,6 @@ export function createToolFlow(config: ToolFlowConfig) { {config.steps.map((stepConfig) => steps.create(stepConfig.title, { isVisible: stepConfig.isVisible, - isCollapsed: stepConfig.isCollapsed, onCollapsedClick: stepConfig.onCollapsedClick, tooltip: stepConfig.tooltip }, stepConfig.content) diff --git a/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts b/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts index f7502592fb..cde2cd33b9 100644 --- a/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts +++ b/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts @@ -13,14 +13,14 @@ export const useOverlayPdfsTips = (): TooltipContent => { title: t('overlay-pdfs.tooltip.description.title', 'Description'), description: t( 'overlay-pdfs.tooltip.description.text', - 'Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.' + 'Combine a original PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.' ) }, { title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'), description: t( 'overlay-pdfs.tooltip.mode.text', - 'Choose how to distribute overlay pages across the base PDF pages.' + 'Choose how to distribute overlay pages across the original PDF pages.' ), bullets: [ t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'), @@ -39,7 +39,7 @@ export const useOverlayPdfsTips = (): TooltipContent => { title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'), description: t( 'overlay-pdfs.tooltip.overlayFiles.text', - 'Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.' + 'Select one or more PDFs to overlay on the original. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.' ) }, { diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 95153f5d26..cabc8352cd 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -62,8 +62,8 @@ export function useViewerRightRailButtons() { render: ({ disabled }) => ( { @@ -71,6 +71,7 @@ export function useViewerRightRailButtons() { setIsPanning(prev => !prev); }} disabled={disabled} + style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined} > diff --git a/frontend/src/core/contexts/FilesModalContext.tsx b/frontend/src/core/contexts/FilesModalContext.tsx index 468609f7d9..aa1d79d1ca 100644 --- a/frontend/src/core/contexts/FilesModalContext.tsx +++ b/frontend/src/core/contexts/FilesModalContext.tsx @@ -1,7 +1,9 @@ import React, { createContext, useContext, useState, useCallback, useMemo } from 'react'; import { useFileHandler } from '@app/hooks/useFileHandler'; import { useFileActions } from '@app/contexts/FileContext'; +import { useFileContext } from '@app/contexts/file/fileHooks'; import { StirlingFileStub } from '@app/types/fileContext'; +import type { FileId } from '@app/types/file'; import { fileStorage } from '@app/services/fileStorage'; interface FilesModalContextType { @@ -19,6 +21,7 @@ const FilesModalContext = createContext(null); export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { addFiles } = useFileHandler(); const { actions } = useFileActions(); + const fileCtx = useFileContext(); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); const [insertAfterPage, setInsertAfterPage] = useState(); @@ -37,16 +40,25 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch onModalClose?.(); }, [onModalClose]); - const handleFileUpload = useCallback((files: File[]) => { + const handleFileUpload = useCallback(async (files: File[]) => { if (customHandler) { // Use custom handler for special cases (like page insertion) customHandler(files, insertAfterPage); } else { - // Use normal file handling - addFiles(files); + // 1) Add via standard flow (auto-selects new files) + await addFiles(files); + // 2) Merge all requested file IDs (covers already-present files too) + const ids = files + .map((f) => fileCtx.findFileId(f) as FileId | undefined) + .filter((id): id is FileId => Boolean(id)); + if (ids.length > 0) { + const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id); + const nextSelection = Array.from(new Set([...currentSelected, ...ids])); + actions.setSelectedFiles(nextSelection); + } } closeFilesModal(); - }, [addFiles, closeFilesModal, insertAfterPage, customHandler]); + }, [addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx]); const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => { if (customHandler) { @@ -67,15 +79,22 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch console.error('Failed to load files for custom handler:', error); } } else { - // Normal case - use addStirlingFileStubs to preserve metadata + // Normal case - use addStirlingFileStubs to preserve metadata (auto-selects new) if (actions.addStirlingFileStubs) { - actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true }); + await actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true }); + // Merge all requested IDs into selection (covers files that already existed) + const requestedIds = stirlingFileStubs.map((s) => s.id); + if (requestedIds.length > 0) { + const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id); + const nextSelection = Array.from(new Set([...currentSelected, ...requestedIds])); + actions.setSelectedFiles(nextSelection); + } } else { console.error('addStirlingFileStubs action not available'); } } closeFilesModal(); - }, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]); + }, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage, actions, fileCtx]); const setModalCloseCallback = useCallback((callback: () => void) => { setOnModalClose(() => callback); diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index 2c86700129..7c657506ea 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -421,4 +421,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue { throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider'); } return context; -} +} \ No newline at end of file diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index fca3ab9b04..82c02b2330 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -122,8 +122,11 @@ import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbe import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings"; import ValidateSignature from "@app/tools/ValidateSignature"; import Automate from "@app/tools/Automate"; +import Compare from "@app/tools/Compare"; import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats"; + + export interface TranslatedToolCatalog { allTools: ToolRegistry; regularTools: RegularToolRegistry; @@ -772,13 +775,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { compare: { icon: , name: t("home.compare.title", "Compare"), - component: null, + component: Compare, description: t("home.compare.desc", "Compare two PDF documents and highlight differences"), - categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */, + categoryId: ToolCategoryId.RECOMMENDED_TOOLS, subcategoryId: SubcategoryId.GENERAL, + maxFiles: 2, + operationConfig: undefined, + automationSettings: null, synonyms: getSynonyms(t, "compare"), - supportsAutomate: false, - automationSettings: null + supportsAutomate: false }, compress: { icon: , diff --git a/frontend/src/core/hooks/tools/compare/operationUtils.ts b/frontend/src/core/hooks/tools/compare/operationUtils.ts new file mode 100644 index 0000000000..552ee8dd37 --- /dev/null +++ b/frontend/src/core/hooks/tools/compare/operationUtils.ts @@ -0,0 +1,566 @@ +import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; +import { appendWord as sharedAppendWord } from '@app/utils/textDiff'; +import { PARAGRAPH_SENTINEL } from '@app/types/compare'; +import type { StirlingFile } from '@app/types/fileContext'; +import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { + CompareChange, + CompareDiffToken, + CompareResultData, + TokenBoundingBox, + CompareParagraph, +} from '@app/types/compare'; + +export interface TokenMetadata { + page: number; + paragraph: number; + bbox: TokenBoundingBox | null; +} + +export interface ExtractedContent { + tokens: string[]; + metadata: TokenMetadata[]; + pageSizes: { width: number; height: number }[]; + paragraphs: CompareParagraph[]; +} + +const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null; +const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null; +const textMeasurementCache: Map | null = measurementContext ? new Map() : null; +let lastMeasurementFont = ''; + +const DEFAULT_CHAR_WIDTH = 1; +const DEFAULT_SPACE_WIDTH = 0.33; + +export const measureTextWidth = (fontSpec: string, text: string): number => { + if (!measurementContext) { + if (!text) return 0; + if (text === ' ') return DEFAULT_SPACE_WIDTH; + return text.length * DEFAULT_CHAR_WIDTH; + } + + if (lastMeasurementFont !== fontSpec) { + measurementContext.font = fontSpec; + lastMeasurementFont = fontSpec; + } + + const key = `${fontSpec}|${text}`; + const cached = textMeasurementCache?.get(key); + if (cached !== undefined) { + return cached; + } + + const width = measurementContext.measureText(text).width || 0; + textMeasurementCache?.set(key, width); + return width; +}; + +export const appendWord = (existing: string, word: string) => { + if (!existing) { + return sharedAppendWord('', word); + } + return sharedAppendWord(existing, word); +}; + +export const aggregateTotals = (tokens: CompareDiffToken[]) => { + return tokens.reduce( + (totals, token) => { + if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized + return totals; + } + switch (token.type) { + case 'added': + totals.added += 1; + break; + case 'removed': + totals.removed += 1; + break; + default: + totals.unchanged += 1; + } + return totals; + }, + { added: 0, removed: 0, unchanged: 0 } + ); +}; + +export const buildChanges = ( + tokens: CompareDiffToken[], + baseMetadata: TokenMetadata[], + comparisonMetadata: TokenMetadata[] +): CompareChange[] => { + const changes: CompareChange[] = []; + let baseIndex = 0; + let comparisonIndex = 0; + let current: CompareChange | null = null; + let currentBaseParagraph: number | null = null; + let currentComparisonParagraph: number | null = null; + + const ensureCurrent = (): CompareChange => { + if (!current) { + current = { + id: `change-${changes.length}`, + base: null, + comparison: null, + }; + } + return current; + }; + + const flush = () => { + if (current) { + if (current.base) { + current.base.text = current.base.text.trim(); + } + if (current.comparison) { + current.comparison.text = current.comparison.text.trim(); + } + + if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) { + changes.push(current); + } + } + current = null; + currentBaseParagraph = null; + currentComparisonParagraph = null; + }; + + for (const token of tokens) { + if (token.type === 'removed') { + const meta = baseMetadata[baseIndex] ?? null; + const active = ensureCurrent(); + const paragraph = meta?.paragraph ?? null; + if (!active.base) { + active.base = { + text: token.text, + page: meta?.page ?? null, + paragraph: meta?.paragraph ?? null, + }; + currentBaseParagraph = paragraph; + } else { + if ( + paragraph !== null && + currentBaseParagraph !== null && + paragraph !== currentBaseParagraph && + active.base.text.trim().length > 0 + ) { + flush(); + const next = ensureCurrent(); + next.base = { + text: token.text, + page: meta?.page ?? null, + paragraph: paragraph, + }; + } else { + active.base.text = appendWord(active.base.text, token.text); + } + if (meta && active.base.page === null) { + active.base.page = meta.page; + } + if (meta && active.base.paragraph === null) { + active.base.paragraph = meta.paragraph; + } + if (paragraph !== null) { + currentBaseParagraph = paragraph; + } + } + if (baseIndex < baseMetadata.length) { + baseIndex += 1; + } + continue; + } + + if (token.type === 'added') { + const meta = comparisonMetadata[comparisonIndex] ?? null; + const active = ensureCurrent(); + const paragraph = meta?.paragraph ?? null; + if (!active.comparison) { + active.comparison = { + text: token.text, + page: meta?.page ?? null, + paragraph: meta?.paragraph ?? null, + }; + currentComparisonParagraph = paragraph; + } else { + if ( + paragraph !== null && + currentComparisonParagraph !== null && + paragraph !== currentComparisonParagraph && + active.comparison.text.trim().length > 0 + ) { + flush(); + const next = ensureCurrent(); + next.comparison = { + text: token.text, + page: meta?.page ?? null, + paragraph: paragraph, + }; + } else { + active.comparison.text = appendWord(active.comparison.text, token.text); + } + if (meta && active.comparison.page === null) { + active.comparison.page = meta.page; + } + if (meta && active.comparison.paragraph === null) { + active.comparison.paragraph = meta.paragraph; + } + if (paragraph !== null) { + currentComparisonParagraph = paragraph; + } + } + if (comparisonIndex < comparisonMetadata.length) { + comparisonIndex += 1; + } + continue; + } + + // unchanged token + flush(); + if (baseIndex < baseMetadata.length) { + baseIndex += 1; + } + if (comparisonIndex < comparisonMetadata.length) { + comparisonIndex += 1; + } + } + + flush(); + + return changes; +}; + +export const createSummaryFile = (result: CompareResultData): File => { + const exportPayload = { + generatedAt: new Date(result.totals.processedAt).toISOString(), + base: { + name: result.base.fileName, + totalWords: result.base.wordCount, + }, + comparison: { + name: result.comparison.fileName, + totalWords: result.comparison.wordCount, + }, + totals: { + added: result.totals.added, + removed: result.totals.removed, + unchanged: result.totals.unchanged, + durationMs: result.totals.durationMs, + }, + changes: result.changes.map((change) => ({ + base: change.base, + comparison: change.comparison, + })), + warnings: result.warnings, + }; + + const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`; + return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' }); +}; + +export const clamp = (value: number): number => Math.min(1, Math.max(0, value)); + +export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' | undefined => { + if (typeof value === 'object' && value !== null && 'code' in value) { + const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' }).code; + return potentialCode; + } + return undefined; +}; + +// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers, +// returning a mapping to original indices for potential future use. +export const filterTokensForDiff = ( + tokens: string[], + metadata: TokenMetadata[], +): { tokens: string[]; metadata: TokenMetadata[]; filteredToOriginal: number[] } => { + const outTokens: string[] = []; + const outMeta: TokenMetadata[] = []; + const map: number[] = []; + for (let i = 0; i < tokens.length; i += 1) { + const t = tokens[i]; + const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA'); + if (!isPara) { + outTokens.push(t); + if (metadata[i]) outMeta.push(metadata[i]); + map.push(i); + } + } + return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map }; +}; + +export const extractContentFromPdf = async (file: StirlingFile): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, { + disableAutoFetch: true, + disableStream: true, + }); + + try { + const tokens: string[] = []; + const metadata: TokenMetadata[] = []; + const pageSizes: { width: number; height: number }[] = []; + const paragraphs: CompareParagraph[] = []; + for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) { + const page: PDFPageProxy = await pdfDoc.getPage(pageIndex); + const viewport = page.getViewport({ scale: 1 }); + const content: TextContent = await page.getTextContent({ + disableCombineTextItems: true, + } as Parameters[0]); + const styles: Record = content.styles ?? {}; + + let paragraphIndex = 1; + let paragraphBuffer = ''; + let prevItem: TextItem | null = null; + + pageSizes.push({ width: viewport.width, height: viewport.height }); + + const normalizeToken = (s: string) => + s + .normalize('NFKC') + .replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '') + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[–—]/g, '-') + .replace(/\u00A0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => { + const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL; + if (hasHardBreak) return true; + if (!prev) return false; + const prevY = prev.transform[5]; + const currY = curr.transform[5]; + const dy = Math.abs(currY - prevY); + const currX = curr.transform[4]; + const prevX = prev.transform[4]; + const approxLine = Math.max(10, Math.abs((curr as any).height ?? 0) * 0.9); + const looksLikeParagraph = dy > approxLine * 1.8; + const likelySoftWrap = currX < prevX && dy < approxLine * 0.6; + return looksLikeParagraph && !likelySoftWrap; + }; + + const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => { + if (width <= 0 || height <= 0) { + return null; + } + + const MIN_WIDTH = 0.004; + const MIN_HORIZONTAL_PAD = 0.0012; + const HORIZONTAL_PAD_RATIO = 0.12; + const MIN_VERTICAL_PAD = 0.0008; + const VERTICAL_PAD_RATIO = 0.18; + + const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD); + const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD); + + let expandedLeft = left - horizontalPad; + let expandedRight = left + width + horizontalPad; + let expandedTop = top - verticalPad; + let expandedBottom = top + height + verticalPad; + + if (expandedRight - expandedLeft < MIN_WIDTH) { + const deficit = MIN_WIDTH - (expandedRight - expandedLeft); + expandedLeft -= deficit / 2; + expandedRight += deficit / 2; + } + + expandedLeft = clamp(expandedLeft); + expandedRight = clamp(expandedRight); + expandedTop = clamp(expandedTop); + expandedBottom = clamp(expandedBottom); + + if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) { + return null; + } + + return { + left: expandedLeft, + top: expandedTop, + width: expandedRight - expandedLeft, + height: expandedBottom - expandedTop, + }; + }; + + for (const item of content.items as TextItem[]) { + if (!item?.str) { + prevItem = null; + continue; + } + + const rawText = item.str; + const totalLen = Math.max(rawText.length, 1); + const textStyle = item.fontName ? styles[item.fontName] : undefined; + const fontFamily = textStyle?.fontFamily ?? 'sans-serif'; + const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0); + const fontSpec = `${fontScale}px ${fontFamily}`; + + const weights: number[] = new Array(totalLen); + let runningText = ''; + let previousAdvance = 0; + for (let i = 0; i < totalLen; i += 1) { + runningText += rawText[i]; + const advance = measureTextWidth(fontSpec, runningText); + let width = advance - previousAdvance; + if (!Number.isFinite(width) || width <= 0) { + width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH; + } + weights[i] = width; + previousAdvance = advance; + } + if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) { + for (let i = 0; i < totalLen; i += 1) { + weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH; + } + } + const prefix: number[] = new Array(totalLen + 1); + prefix[0] = 0; + for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i]; + const totalWeight = prefix[totalLen] || 1; + + const rawX = item.transform[4]; + const rawY = item.transform[5]; + const transformed = [ + viewport.convertToViewportPoint(rawX, rawY), + viewport.convertToViewportPoint(rawX + item.width, rawY), + viewport.convertToViewportPoint(rawX, rawY + item.height), + viewport.convertToViewportPoint(rawX + item.width, rawY + item.height), + ]; + const xs = transformed.map(([px]) => px); + const ys = transformed.map(([, py]) => py); + const left = Math.min(...xs); + const right = Math.max(...xs); + const top = Math.min(...ys); + const bottom = Math.max(...ys); + + if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) { + prevItem = item; + continue; + } + + const [baselineStart, baselineEnd, verticalEnd] = transformed; + const baselineVector: [number, number] = [ + baselineEnd[0] - baselineStart[0], + baselineEnd[1] - baselineStart[1], + ]; + const verticalVector: [number, number] = [ + verticalEnd[0] - baselineStart[0], + verticalEnd[1] - baselineStart[1], + ]; + const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]); + const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]); + const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6; + + const font = item.fontName ? styles[item.fontName] : undefined; + const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9; + const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2; + const verticalScale = Math.min(1, Math.max(0.75, ascent + descent)); + + const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g; + let match: RegExpExecArray | null; + while ((match = wordRegex.exec(rawText)) !== null) { + const wordRaw = match[0]; + const normalizedWord = normalizeToken(wordRaw); + if (!normalizedWord) { + continue; + } + const startIndex = match.index; + const endIndex = startIndex + wordRaw.length; + + const relStart = prefix[startIndex] / totalWeight; + const relEnd = prefix[endIndex] / totalWeight; + + let wordLeftAbs: number; + let wordRightAbs: number; + let wordTopAbs: number; + let wordBottomAbs: number; + + if (hasOrientationVectors) { + const segStart: [number, number] = [ + baselineStart[0] + baselineVector[0] * relStart, + baselineStart[1] + baselineVector[1] * relStart, + ]; + const segEnd: [number, number] = [ + baselineStart[0] + baselineVector[0] * relEnd, + baselineStart[1] + baselineVector[1] * relEnd, + ]; + const cornerPoints: Array<[number, number]> = [ + segStart, + [segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]], + [segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]], + segEnd, + ]; + const cornerXs = cornerPoints.map(([px]) => px); + const cornerYs = cornerPoints.map(([, py]) => py); + wordLeftAbs = Math.min(...cornerXs); + wordRightAbs = Math.max(...cornerXs); + wordTopAbs = Math.min(...cornerYs); + wordBottomAbs = Math.max(...cornerYs); + } else { + const segLeftAbs = left + (right - left) * relStart; + const segRightAbs = left + (right - left) * relEnd; + wordLeftAbs = Math.min(segLeftAbs, segRightAbs); + wordRightAbs = Math.max(segLeftAbs, segRightAbs); + wordTopAbs = top; + wordBottomAbs = bottom; + } + + const wordLeft = clamp(wordLeftAbs / viewport.width); + const wordRight = clamp(wordRightAbs / viewport.width); + const wordTop = clamp(wordTopAbs / viewport.height); + const wordBottom = clamp(wordBottomAbs / viewport.height); + const wordWidth = Math.max(0, wordRight - wordLeft); + let wordHeight = Math.max(0, wordBottom - wordTop); + + if (wordHeight > 0 && verticalScale < 1) { + const midY = (wordTop + wordBottom) / 2; + const shrunkHeight = Math.max(0, wordHeight * verticalScale); + const half = shrunkHeight / 2; + const newTop = clamp(midY - half); + const newBottom = clamp(midY + half); + wordHeight = Math.max(0, newBottom - newTop); + const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight); + tokens.push(normalizedWord); + metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox }); + paragraphBuffer = appendWord(paragraphBuffer, normalizedWord); + continue; + } + + const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight); + + tokens.push(normalizedWord); + metadata.push({ + page: pageIndex, + paragraph: paragraphIndex, + bbox, + }); + + paragraphBuffer = appendWord(paragraphBuffer, normalizedWord); + } + + if (isParagraphBreak(item as TextItem, prevItem)) { + if (paragraphBuffer.trim().length > 0) { + paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() }); + paragraphBuffer = ''; + } + tokens.push('\uE000PARA'); + metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null }); + paragraphIndex += 1; + } + prevItem = item as TextItem; + } + + if (paragraphBuffer.trim().length > 0) { + paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() }); + paragraphBuffer = ''; + tokens.push('\uE000PARA'); + metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null }); + } + } + return { tokens, metadata, pageSizes, paragraphs }; + } finally { + pdfWorkerManager.destroyDocument(pdfDoc); + } +}; + + diff --git a/frontend/src/core/hooks/tools/compare/useCompareOperation.ts b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts new file mode 100644 index 0000000000..0dd3706f05 --- /dev/null +++ b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts @@ -0,0 +1,559 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ADDITION_HIGHLIGHT, + CompareDiffToken, + CompareFilteredTokenInfo, + CompareResultData, + CompareWorkerRequest, + CompareWorkerResponse, + CompareWorkerWarnings, + REMOVAL_HIGHLIGHT, +} from '@app/types/compare'; +import { CompareParameters } from '@app/hooks/tools/compare/useCompareParameters'; +import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; +import type { StirlingFile } from '@app/types/fileContext'; +import { useFileContext } from '@app/contexts/file/fileHooks'; +import { + aggregateTotals, + buildChanges, + createSummaryFile, + extractContentFromPdf, + getWorkerErrorCode, + filterTokensForDiff, +} from '@app/hooks/tools/compare/operationUtils'; +import { alert, dismissToast } from '@app/components/toast'; +import type { ToastLocation } from '@app/components/toast/types'; +import CompareWorkerCtor from '@app/workers/compareWorker?worker'; +const LONG_RUNNING_PAGE_THRESHOLD = 2000; + +export interface CompareOperationHook extends ToolOperationHook { + result: CompareResultData | null; + warnings: string[]; +} + +// extractContentFromPdf moved to utils + +export const useCompareOperation = (): CompareOperationHook => { + const { t } = useTranslation(); + const { selectors } = useFileContext(); + const workerRef = useRef(null); + const previousUrl = useRef(null); + const activeRunIdRef = useRef(0); + const cancelledRef = useRef(false); + + type OperationStatus = 'idle' | 'extracting' | 'processing' | 'complete' | 'cancelled' | 'error'; + const [isLoading, setIsLoading] = useState(false); + const [statusState, setStatusState] = useState('idle'); + const [statusDetailMs, setStatusDetailMs] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [files, setFiles] = useState([]); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [result, setResult] = useState(null); + const [warnings, setWarnings] = useState([]); + const longRunningToastIdRef = useRef(null); + const dissimilarityToastIdRef = useRef(null); + const dissimilarityToastShownRef = useRef(false); + + const ensureWorker = useCallback(() => { + if (!workerRef.current) { + workerRef.current = new CompareWorkerCtor(); + } + return workerRef.current; + }, []); + + const cleanupDownloadUrl = useCallback(() => { + if (previousUrl.current) { + URL.revokeObjectURL(previousUrl.current); + previousUrl.current = null; + } + }, []); + + const resetResults = useCallback(() => { + setResult(null); + setWarnings([]); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + setStatusState('idle'); + setStatusDetailMs(null); + setErrorMessage(null); + }, [cleanupDownloadUrl]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + const runCompareWorker = useCallback( + async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings, onChunk?: (chunk: CompareDiffToken[]) => void) => { + const worker = ensureWorker(); + + return await new Promise<{ + tokens: CompareDiffToken[]; + stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number }; + warnings: string[]; + }>((resolve, reject) => { + const collectedWarnings: string[] = []; + const collectedTokens: CompareDiffToken[] = []; + + const handleMessage = (event: MessageEvent) => { + if (cancelledRef.current) { + cleanup(); + reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const })); + return; + } + const message = event.data; + if (!message) { + return; + } + + switch (message.type) { + case 'chunk': { + if (message.tokens.length > 0) { + collectedTokens.push(...message.tokens); + onChunk?.(message.tokens); + } + break; + } + case 'success': + cleanup(); + if (longRunningToastIdRef.current) { + dismissToast(longRunningToastIdRef.current); + longRunningToastIdRef.current = null; + } + resolve({ + tokens: collectedTokens, + stats: message.stats, + warnings: collectedWarnings, + }); + break; + case 'warning': + collectedWarnings.push(message.message); + break; + case 'error': { + cleanup(); + if (longRunningToastIdRef.current) { + dismissToast(longRunningToastIdRef.current); + longRunningToastIdRef.current = null; + } + const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' } = new Error(message.message); + error.code = message.code; + reject(error); + break; + } + default: + break; + } + }; + + const handleError = (event: ErrorEvent) => { + cleanup(); + if (cancelledRef.current) { + reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const })); + } else { + reject(event.error ?? new Error(event.message)); + } + }; + + const cleanup = () => { + worker.removeEventListener('message', handleMessage as EventListener); + worker.removeEventListener('error', handleError as EventListener); + }; + + worker.addEventListener('message', handleMessage as EventListener); + worker.addEventListener('error', handleError as EventListener); + + const request: CompareWorkerRequest = { + type: 'compare', + payload: { + baseTokens, + comparisonTokens, + warnings: warningMessages, + // Static worker settings to support large documents + settings: { + batchSize: 5000, + complexThreshold: 120000, + maxWordThreshold: 200000, + }, + }, + }; + + worker.postMessage(request); + }); + }, + [ensureWorker] + ); + + const executeOperation = useCallback( + async (params: CompareParameters, selectedFiles: StirlingFile[]) => { + // start new run + const runId = ++activeRunIdRef.current; + cancelledRef.current = false; + if (!params.baseFileId || !params.comparisonFileId) { + setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.')); + return; + } + + const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId) + ?? selectors.getFile(params.baseFileId); + const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId) + ?? selectors.getFile(params.comparisonFileId); + + if (!baseFile || !comparisonFile) { + setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.')); + return; + } + + setIsLoading(true); + setStatusState('extracting'); + setStatusDetailMs(null); + setErrorMessage(null); + setWarnings([]); + setResult(null); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + + const warningMessages: CompareWorkerWarnings = { + // No accuracy warning any more + tooLargeMessage: t( + 'compare.large.file.message', + 'These documents are very large; comparison may take several minutes. Please keep this tab open.' + ), + emptyTextMessage: t( + 'compare.no.text.message', + 'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.' + ), + tooDissimilarMessage: t( + 'compare.too.dissimilar.message', + 'These documents appear highly dissimilar. Comparison was stopped to save time.' + ), + }; + + const operationStart = performance.now(); + + try { + const [baseContent, comparisonContent] = await Promise.all([ + extractContentFromPdf(baseFile), + extractContentFromPdf(comparisonFile), + ]); + + if (cancelledRef.current || activeRunIdRef.current !== runId) return; + + if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) { + throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' }); + } + + setStatusState('processing'); + + // Filter out paragraph sentinels before diffing to avoid large false-positive runs + const baseFiltered = filterTokensForDiff(baseContent.tokens, baseContent.metadata); + const comparisonFiltered = filterTokensForDiff(comparisonContent.tokens, comparisonContent.metadata); + + const combinedPageCount = + (baseContent.pageSizes?.length ?? 0) + (comparisonContent.pageSizes?.length ?? 0); + + if ( + combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD && + !longRunningToastIdRef.current + ) { + const toastId = alert({ + alertType: 'neutral', + title: t('compare.longJob.title', 'Large comparison in progress'), + body: t( + 'compare.longJob.body', + 'These PDFs together exceed 2,000 pages. Processing can take several minutes.' + ), + location: 'bottom-right' as ToastLocation, + isPersistentPopup: true, + expandable: false, + }); + longRunningToastIdRef.current = toastId || null; + } + + // Heuristic: surface an early warning toast when we observe a very high ratio of differences + const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning + const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged + let observedAddedRemoved = 0; + let observedUnchanged = 0; + + const handleEarlyDissimilarity = () => { + if (dissimilarityToastShownRef.current || dissimilarityToastIdRef.current) return; + const toastId = alert({ + alertType: 'warning', + title: t('compare.earlyDissimilarity.title', 'These PDFs look highly different'), + body: t( + 'compare.earlyDissimilarity.body', + "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents." + ), + location: 'bottom-right' as ToastLocation, + isPersistentPopup: true, + expandable: false, + buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'), + buttonCallback: () => { + try { cancelOperation(); } catch { + console.error('Failed to cancel operation'); + } + try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch { + console.error('Failed to dispatch clear selected event'); + } + if (dissimilarityToastIdRef.current) { + dismissToast(dissimilarityToastIdRef.current); + dissimilarityToastIdRef.current = null; + } + }, + }); + dissimilarityToastIdRef.current = toastId || null; + dissimilarityToastShownRef.current = true; + }; + + const { tokens, stats, warnings: workerWarnings } = await runCompareWorker( + baseFiltered.tokens, + comparisonFiltered.tokens, + warningMessages, + (chunk) => { + // Incremental ratio tracking for early warning + for (const tok of chunk) { + if (tok.type === 'unchanged') observedUnchanged += 1; + else observedAddedRemoved += 1; + } + const seen = observedAddedRemoved + observedUnchanged; + if ( + !dissimilarityToastShownRef.current && + seen >= EARLY_TOAST_MIN_TOKENS && + observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO + ) { + handleEarlyDissimilarity(); + } + } + ); + + if (cancelledRef.current || activeRunIdRef.current !== runId) return; + + const baseHasHighlight = new Array(baseFiltered.tokens.length).fill(false); + const comparisonHasHighlight = new Array(comparisonFiltered.tokens.length).fill(false); + + let baseTokenPointer = 0; + let comparisonTokenPointer = 0; + for (const diffToken of tokens) { + if (diffToken.type === 'removed') { + if (baseTokenPointer < baseHasHighlight.length) { + baseHasHighlight[baseTokenPointer] = true; + } + baseTokenPointer += 1; + } else if (diffToken.type === 'added') { + if (comparisonTokenPointer < comparisonHasHighlight.length) { + comparisonHasHighlight[comparisonTokenPointer] = true; + } + comparisonTokenPointer += 1; + } else { + if (baseTokenPointer < baseHasHighlight.length) { + baseTokenPointer += 1; + } + if (comparisonTokenPointer < comparisonHasHighlight.length) { + comparisonTokenPointer += 1; + } + } + } + + const buildFilteredTokenData = ( + tokensList: typeof baseFiltered.tokens, + metadataList: typeof baseFiltered.metadata, + highlightFlags: boolean[] + ): CompareFilteredTokenInfo[] => + tokensList.map((token, index) => { + const meta = metadataList[index]; + return { + token, + page: meta?.page ?? null, + paragraph: meta?.paragraph ?? null, + bbox: meta?.bbox ?? null, + hasHighlight: highlightFlags[index] ?? false, + metaIndex: index, + }; + }); + + const totals = aggregateTotals(tokens); + const processedAt = Date.now(); + + const baseMetadata = baseFiltered.metadata; + const comparisonMetadata = comparisonFiltered.metadata; + + const changes = buildChanges(tokens, baseMetadata, comparisonMetadata); + + const comparisonResult: CompareResultData = { + base: { + fileId: baseFile.fileId, + fileName: baseFile.name, + highlightColor: REMOVAL_HIGHLIGHT, + wordCount: stats.baseWordCount, + pageSizes: baseContent.pageSizes, + }, + comparison: { + fileId: comparisonFile.fileId, + fileName: comparisonFile.name, + highlightColor: ADDITION_HIGHLIGHT, + wordCount: stats.comparisonWordCount, + pageSizes: comparisonContent.pageSizes, + }, + totals: { + ...totals, + durationMs: stats.durationMs, + processedAt, + }, + tokens, + tokenMetadata: { + base: baseMetadata, + comparison: comparisonMetadata, + }, + filteredTokenData: { + base: buildFilteredTokenData(baseFiltered.tokens, baseFiltered.metadata, baseHasHighlight), + comparison: buildFilteredTokenData( + comparisonFiltered.tokens, + comparisonFiltered.metadata, + comparisonHasHighlight + ), + }, + sourceTokens: { + base: baseContent.tokens, + comparison: comparisonContent.tokens, + }, + changes, + warnings: workerWarnings, + baseParagraphs: baseContent.paragraphs, + comparisonParagraphs: comparisonContent.paragraphs, + }; + + setResult(comparisonResult); + setWarnings(workerWarnings); + + const summaryFile = createSummaryFile(comparisonResult); + setFiles([summaryFile]); + + cleanupDownloadUrl(); + const blobUrl = URL.createObjectURL(summaryFile); + previousUrl.current = blobUrl; + setDownloadUrl(blobUrl); + setDownloadFilename(summaryFile.name); + + setStatusState('complete'); + } catch (error: unknown) { + console.error('[compare] operation failed', error); + const errorCode = getWorkerErrorCode(error); + if (errorCode === 'EMPTY_TEXT') { + setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.')); + } else { + const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.'); + if (error instanceof Error && error.message) { + setErrorMessage(error.message); + } else if (typeof error === 'string' && error.trim().length > 0) { + setErrorMessage(error); + } else { + setErrorMessage(fallbackMessage); + } + } + } finally { + const duration = performance.now() - operationStart; + setStatusDetailMs(Math.round(duration)); + setIsLoading(false); + if (longRunningToastIdRef.current) { + dismissToast(longRunningToastIdRef.current); + longRunningToastIdRef.current = null; + } + if (dissimilarityToastIdRef.current) { + dismissToast(dissimilarityToastIdRef.current); + dissimilarityToastIdRef.current = null; + } + dissimilarityToastShownRef.current = false; + } + }, + [cleanupDownloadUrl, runCompareWorker, selectors, t] + ); + + const cancelOperation = useCallback(() => { + if (!isLoading) return; + cancelledRef.current = true; + setIsLoading(false); + setStatusState('cancelled'); + if (workerRef.current) { + try { + workerRef.current.terminate(); + // eslint-disable-next-line no-empty + } catch {} + workerRef.current = null; + } + if (longRunningToastIdRef.current) { + dismissToast(longRunningToastIdRef.current); + longRunningToastIdRef.current = null; + } + }, [isLoading]); + + const undoOperation = useCallback(async () => { + resetResults(); + }, [resetResults]); + + useEffect(() => { + return () => { + cleanupDownloadUrl(); + if (workerRef.current) { + workerRef.current.terminate(); + workerRef.current = null; + } + if (longRunningToastIdRef.current) { + dismissToast(longRunningToastIdRef.current); + longRunningToastIdRef.current = null; + } + }; + }, [cleanupDownloadUrl]); + + const status = useMemo(() => { + const label = + statusState === 'idle' ? '' + : statusState === 'extracting' ? t('compare.status.extracting', 'Extracting text...') + : statusState === 'processing' ? t('compare.status.processing', 'Analyzing differences...') + : statusState === 'complete' ? t('compare.status.complete', 'Comparison ready') + : statusState === 'cancelled' ? t('operationCancelled', 'Operation cancelled') + : ''; + if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`; + return label; + }, [statusState, statusDetailMs, t]); + + return useMemo( + () => ({ + files, + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl, + downloadFilename, + isLoading, + status, + errorMessage, + progress: null, + executeOperation, + resetResults, + clearError, + cancelOperation, + undoOperation, + result, + warnings, + }), + [ + cancelOperation, + clearError, + downloadFilename, + downloadUrl, + errorMessage, + executeOperation, + files, + isLoading, + resetResults, + result, + status, + undoOperation, + warnings, + ] + ); +}; diff --git a/frontend/src/core/hooks/tools/compare/useCompareParameters.ts b/frontend/src/core/hooks/tools/compare/useCompareParameters.ts new file mode 100644 index 0000000000..98cba87539 --- /dev/null +++ b/frontend/src/core/hooks/tools/compare/useCompareParameters.ts @@ -0,0 +1,23 @@ +import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters'; +import type { FileId } from '@app/types/file'; + +export interface CompareParameters { + baseFileId: FileId | null; + comparisonFileId: FileId | null; +} + +export const defaultParameters: CompareParameters = { + baseFileId: null, + comparisonFileId: null, +}; + +export type CompareParametersHook = BaseParametersHook; + +export const useCompareParameters = (): CompareParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'compare', + validateFn: (params) => + Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId), + }); +}; diff --git a/frontend/src/core/hooks/useFileHandler.ts b/frontend/src/core/hooks/useFileHandler.ts index 93c7fe2864..fc935b0298 100644 --- a/frontend/src/core/hooks/useFileHandler.ts +++ b/frontend/src/core/hooks/useFileHandler.ts @@ -1,14 +1,16 @@ import { useCallback } from 'react'; import { useFileActions } from '@app/contexts/FileContext'; +import type { StirlingFile } from '@app/types/fileContext'; export const useFileHandler = () => { const { actions } = useFileActions(); - const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}) => { + const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}): Promise => { // Merge default options with passed options - passed options take precedence const mergedOptions = { selectFiles: true, ...options }; // Let FileContext handle deduplication with quickKey logic - await actions.addFiles(files, mergedOptions); + const result = await actions.addFiles(files, mergedOptions); + return result; }, [actions.addFiles]); return { diff --git a/frontend/src/core/hooks/useIsMobile.ts b/frontend/src/core/hooks/useIsMobile.ts new file mode 100644 index 0000000000..aab30293c8 --- /dev/null +++ b/frontend/src/core/hooks/useIsMobile.ts @@ -0,0 +1,9 @@ +import { useMediaQuery } from '@mantine/hooks'; + +/** + * Custom hook to detect mobile viewport + * Uses a consistent breakpoint across the application + */ +export const useIsMobile = (): boolean => { + return useMediaQuery('(max-width: 1024px)') ?? false; +}; diff --git a/frontend/src/core/hooks/useProgressivePagePreviews.ts b/frontend/src/core/hooks/useProgressivePagePreviews.ts new file mode 100644 index 0000000000..ef62af4faa --- /dev/null +++ b/frontend/src/core/hooks/useProgressivePagePreviews.ts @@ -0,0 +1,279 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { pdfWorkerManager } from '@app/services/pdfWorkerManager'; +import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs'; +import { PagePreview } from '@app/types/compare'; + +const DISPLAY_SCALE = 1; +const BATCH_SIZE = 10; // Render 10 pages at a time + +const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1); + +interface ProgressivePagePreviewsOptions { + file: File | null; + enabled: boolean; + cacheKey: number | null; + visiblePageRange?: { start: number; end: number }; // 0-based page indices +} + +interface ProgressivePagePreviewsState { + pages: PagePreview[]; + loading: boolean; + totalPages: number; + loadedPages: Set; // 0-based page indices that have been loaded + loadingPages: Set; // 0-based page indices currently being loaded +} + +export const useProgressivePagePreviews = ({ + file, + enabled, + cacheKey, + visiblePageRange, +}: ProgressivePagePreviewsOptions) => { + const [state, setState] = useState({ + pages: [], + loading: false, + totalPages: 0, + loadedPages: new Set(), + loadingPages: new Set(), + }); + + const pdfRef = useRef(null); + const abortControllerRef = useRef(null); + + const renderPageBatch = useCallback(async ( + pdf: PDFDocumentProxy, + pageNumbers: number[], + signal: AbortSignal + ): Promise => { + const previews: PagePreview[] = []; + const dpr = getDevicePixelRatio(); + const renderScale = Math.max(2, Math.min(3, dpr * 2)); + + for (const pageNumber of pageNumbers) { + if (signal.aborted) break; + + try { + const page = await pdf.getPage(pageNumber); + const displayViewport = page.getViewport({ scale: DISPLAY_SCALE }); + const renderViewport = page.getViewport({ scale: renderScale }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = Math.round(renderViewport.width); + canvas.height = Math.round(renderViewport.height); + + if (!context) { + page.cleanup(); + continue; + } + + await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise; + previews.push({ + pageNumber, + width: Math.round(displayViewport.width), + height: Math.round(displayViewport.height), + rotation: (page.rotate || 0) % 360, + url: canvas.toDataURL(), + }); + + page.cleanup(); + canvas.width = 0; + canvas.height = 0; + } catch (error) { + console.error(`[progressive-pages] failed to render page ${pageNumber}:`, error); + } + } + + return previews; + }, []); + + const loadPageRange = useCallback(async ( + startPage: number, + endPage: number, + signal: AbortSignal + ) => { + // Use the live PDF ref for bounds instead of possibly stale state + const totalPages = pdfRef.current?.numPages ?? state.totalPages; + if (startPage < 0 || endPage >= totalPages || startPage > endPage) { + return; + } + + // Check which pages need to be loaded + const pagesToLoad: number[] = []; + for (let i = startPage; i <= endPage; i++) { + if (!state.loadedPages.has(i) && !state.loadingPages.has(i)) { + pagesToLoad.push(i + 1); // Convert to 1-based page numbers + } + } + + if (pagesToLoad.length === 0) return; + + // Mark pages as loading + setState(prev => ({ + ...prev, + loadingPages: new Set([...prev.loadingPages, ...pagesToLoad.map(p => p - 1)]), + })); + + try { + const pdfDoc = pdfRef.current; + if (!pdfDoc) return; + const previews = await renderPageBatch(pdfDoc, pagesToLoad, signal); + + if (!signal.aborted) { + setState(prev => { + const newPages = [...prev.pages]; + const newLoadedPages = new Set(prev.loadedPages); + const newLoadingPages = new Set(prev.loadingPages); + + // Add new previews and mark as loaded + for (const preview of previews) { + const pageIndex = preview.pageNumber - 1; // Convert to 0-based + newLoadedPages.add(pageIndex); + newLoadingPages.delete(pageIndex); + + // Insert preview in correct position + const insertIndex = newPages.findIndex(p => p.pageNumber > preview.pageNumber); + if (insertIndex === -1) { + newPages.push(preview); + } else { + newPages.splice(insertIndex, 0, preview); + } + } + + return { + ...prev, + pages: newPages, + loadedPages: newLoadedPages, + loadingPages: newLoadingPages, + }; + }); + } + } catch (error) { + if (!signal.aborted) { + console.error('[progressive-pages] failed to load page batch:', error); + } + } finally { + if (!signal.aborted) { + setState(prev => { + const newLoadingPages = new Set(prev.loadingPages); + pagesToLoad.forEach(p => newLoadingPages.delete(p - 1)); + return { ...prev, loadingPages: newLoadingPages }; + }); + } + } + }, [state.loadedPages, state.loadingPages, state.totalPages, renderPageBatch]); + + // Initialize PDF and load first batch + useEffect(() => { + let cancelled = false; + + if (!file || !enabled) { + setState({ + pages: [], + loading: false, + totalPages: 0, + loadedPages: new Set(), + loadingPages: new Set(), + }); + return () => { + cancelled = true; + }; + } + + const initialize = async () => { + try { + setState(prev => ({ ...prev, loading: true })); + + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { + disableAutoFetch: true, + disableStream: true, + }); + + if (cancelled) { + pdfWorkerManager.destroyDocument(pdf); + return; + } + + pdfRef.current = pdf; + const totalPages = pdf.numPages; + + setState(prev => ({ + ...prev, + totalPages, + loading: false, + })); + + // Load first batch of pages using a real abort controller + const initAbort = new AbortController(); + const firstBatchEnd = Math.min(BATCH_SIZE - 1, totalPages - 1); + await loadPageRange(0, firstBatchEnd, initAbort.signal); + + } catch (error) { + console.error('[progressive-pages] failed to initialize PDF:', error); + if (!cancelled) { + setState(prev => ({ + ...prev, + loading: false, + totalPages: 0, + })); + } + } + }; + + initialize(); + + return () => { + cancelled = true; + if (pdfRef.current) { + pdfWorkerManager.destroyDocument(pdfRef.current); + pdfRef.current = null; + } + }; + }, [file, enabled, cacheKey, loadPageRange]); + + // Load pages based on visible range + useEffect(() => { + if (!visiblePageRange || state.totalPages === 0) return; + + const { start, end } = visiblePageRange; + const startPage = Math.max(0, start - 5); // Add buffer before + const endPage = Math.min(state.totalPages - 1, end + 5); // Add buffer after + + // Cancel previous loading + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + loadPageRange(startPage, endPage, abortController.signal); + + return () => { + abortController.abort(); + }; + }, [visiblePageRange, state.totalPages, loadPageRange]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + if (pdfRef.current) { + pdfWorkerManager.destroyDocument(pdfRef.current); + } + }; + }, []); + + return { + pages: state.pages, + loading: state.loading, + totalPages: state.totalPages, + loadedPages: state.loadedPages, + loadingPages: state.loadingPages, + }; +}; + +export type UseProgressivePagePreviewsReturn = ReturnType; diff --git a/frontend/src/core/hooks/useShouldShowWelcomeModal.ts b/frontend/src/core/hooks/useShouldShowWelcomeModal.ts index 7de00567a3..953150fb82 100644 --- a/frontend/src/core/hooks/useShouldShowWelcomeModal.ts +++ b/frontend/src/core/hooks/useShouldShowWelcomeModal.ts @@ -1,9 +1,9 @@ -import { useMediaQuery } from '@mantine/hooks'; import { usePreferences } from '@app/contexts/PreferencesContext'; +import { useIsMobile } from '@app/hooks/useIsMobile'; export function useShouldShowWelcomeModal(): boolean { const { preferences } = usePreferences(); - const isMobile = useMediaQuery("(max-width: 1024px)"); + const isMobile = useIsMobile(); return !preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 7c4d3a6c2d..6df688e55d 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -6,7 +6,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext"; import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; import { BASE_PATH } from "@app/constants/app"; import { useBaseUrl } from "@app/hooks/useBaseUrl"; -import { useMediaQuery } from "@mantine/hooks"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import AppsIcon from '@mui/icons-material/AppsRounded'; @@ -45,7 +45,7 @@ export default function HomePage() { const { openFilesModal } = useFilesModalContext(); const { colorScheme } = useMantineColorScheme(); const { config } = useAppConfig(); - const isMobile = useMediaQuery("(max-width: 1024px)"); + const isMobile = useIsMobile(); const sliderRef = useRef(null); const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index c69fcd3d3e..22c90d090a 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -1,3 +1,19 @@ +:root { + /* Compare highlight colors (same in light/dark) */ + --spdf-compare-removed-bg: rgba(255, 107, 107, 0.45); /* #ff6b6b @ 0.45 */ + --spdf-compare-added-bg: rgba(81, 207, 102, 0.35); /* #51cf66 @ 0.35 */ + + /* Badge colors for dropdowns */ + --spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15); + --spdf-compare-removed-badge-fg: #b91c1c; + --spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18); + --spdf-compare-added-badge-fg: #1b5e20; + + /* Inline highlights in summary */ + --spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25); + --spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25); +} + /* CSS variables for Tailwind + Mantine integration */ :root { @@ -174,6 +190,7 @@ --right-rail-foreground: #E3E4E5; /* panel behind custom tool icons */ --right-rail-icon: #4B5563; /* icon color */ --right-rail-icon-disabled: #CECECE;/* disabled icon */ + --right-rail-pan-active-bg: #EAEAEA; /* Colors for tooltips */ --tooltip-title-bg: #DBEFFF; @@ -284,6 +301,17 @@ --pdf-light-report-container-bg: 249 250 251; --pdf-light-simulated-page-bg: 255 255 255; --pdf-light-simulated-page-text: 15 23 42; + + /* Compare tool specific colors - only for colors that don't have existing theme pairs */ + --compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45); + --compare-upload-dropzone-border: rgba(148, 163, 184, 0.6); + --compare-upload-icon-bg: rgba(148, 163, 184, 0.2); + --compare-upload-icon-color: rgba(17, 24, 39, 0.75); + --compare-upload-divider: rgba(148, 163, 184, 0.5); + + /* Compare page label chip (light mode): slightly lighter than surrounding rows */ + --compare-page-label-bg: var(--bg-muted); + --compare-page-label-fg: var(--text-secondary); } [data-mantine-color-scheme="dark"] { @@ -415,6 +443,7 @@ --right-rail-foreground: #2A2F36; /* panel behind custom tool icons */ --right-rail-icon: #BCBEBF; /* icon color */ --right-rail-icon-disabled: #43464B;/* disabled icon */ + --right-rail-pan-active-bg: #EAEAEA; /* Dark mode tooltip colors */ --tooltip-title-bg: #4B525A; @@ -428,6 +457,10 @@ --text-brand: var(--color-gray-800); --text-brand-accent: #EF4444; + /* Compare badge text colors (dark mode): lighter for readability */ + --spdf-compare-removed-badge-fg: var(--color-red-500); + --spdf-compare-added-badge-fg: var(--color-green-500); + /* container */ --landing-paper-bg: #171A1F; --landing-inner-paper-bg: var(--bg-raised); @@ -500,6 +533,17 @@ --modal-nav-item-active-bg: rgba(10, 139, 255, 0.15); --modal-content-bg: #2A2F36; --modal-header-border: rgba(255, 255, 255, 0.08); + + /* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */ + --compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45); + --compare-upload-dropzone-border: rgba(75, 85, 99, 0.6); + --compare-upload-icon-bg: rgba(75, 85, 99, 0.2); + --compare-upload-icon-color: rgba(243, 244, 246, 0.75); + --compare-upload-divider: rgba(75, 85, 99, 0.5); + + /* Compare page label chip (dark mode): slightly darker than surrounding rows */ + --compare-page-label-bg: #1F2329; + --compare-page-label-fg: var(--text-secondary); } /* Dropzone drop state styling */ diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx new file mode 100644 index 0000000000..a2a78a0368 --- /dev/null +++ b/frontend/src/core/tools/Compare.tsx @@ -0,0 +1,596 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import CompareRoundedIcon from '@mui/icons-material/CompareRounded'; +import { Box, Group, Stack, Text, Button, Modal, ActionIcon } from '@mantine/core'; +import SwapVertRoundedIcon from '@mui/icons-material/SwapVertRounded'; +import AddIcon from '@mui/icons-material/Add'; +import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; +import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; +import { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { + useCompareParameters, + defaultParameters as compareDefaultParameters, +} from '@app/hooks/tools/compare/useCompareParameters'; +import { + useCompareOperation, + CompareOperationHook, +} from '@app/hooks/tools/compare/useCompareOperation'; +import CompareWorkbenchView from '@app/components/tools/compare/CompareWorkbenchView'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationActions } from '@app/contexts/NavigationContext'; +import { useFileContext, useFileState } from '@app/contexts/file/fileHooks'; +import type { FileId } from '@app/types/file'; +import type { StirlingFile } from '@app/types/fileContext'; +import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail'; +import type { CompareWorkbenchData } from '@app/types/compare'; +import FitText from '@app/components/shared/FitText'; +import { getDefaultWorkbench } from '@app/types/workbench'; +import { useFilesModalContext } from '@app/contexts/FilesModalContext'; + +const CUSTOM_VIEW_ID = 'compareWorkbenchView'; +const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const; + +const Compare = (props: BaseToolProps) => { + const { t } = useTranslation(); + const { actions: navigationActions } = useNavigationActions(); + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + } = useToolWorkflow(); + const { selectors, actions: fileActions } = useFileContext(); + const { state: fileState } = useFileState(); + const { openFilesModal } = useFilesModalContext(); + + const base = useBaseTool( + 'compare', + useCompareParameters, + useCompareOperation, + props, + { minFiles: 2 } + ); + + const operation = base.operation as CompareOperationHook; + const params = base.params.parameters; + + const compareIcon = useMemo(() => , []); + const [swapConfirmOpen, setSwapConfirmOpen] = useState(false); + const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + const performClearSelected = useCallback(() => { + try { base.operation.cancelOperation(); } catch { console.error('Failed to cancel operation'); } + try { base.operation.resetResults(); } catch { console.error('Failed to reset results'); } + base.params.setParameters(prev => ({ ...prev, baseFileId: null, comparisonFileId: null })); + try { fileActions.clearSelections(); } catch { console.error('Failed to clear selections'); } + clearCustomWorkbenchViewData(CUSTOM_VIEW_ID); + navigationActions.setWorkbench(getDefaultWorkbench()); + }, [base.operation, base.params, clearCustomWorkbenchViewData, fileActions, navigationActions]); + + useEffect(() => { + const handler = () => { + performClearSelected(); + }; + window.addEventListener('compare:clear-selected', handler as unknown as EventListener); + return () => { + window.removeEventListener('compare:clear-selected', handler as unknown as EventListener); + }; + }, [performClearSelected]); + + + useEffect(() => { + registerCustomWorkbenchView({ + id: CUSTOM_VIEW_ID, + workbenchId: CUSTOM_WORKBENCH_ID, + // Use a static label at registration time to avoid re-registering on i18n changes + label: 'Compare view', + icon: compareIcon, + component: CompareWorkbenchView, + }); + + return () => { + unregisterCustomWorkbenchView(CUSTOM_VIEW_ID); + }; + // Register once; avoid re-registering on translation/prop changes which clears data mid-flight + }, []); + + // Auto-map from workbench selection: always reflect the first two selected files in order. + // This also handles deselection by promoting the remaining selection to base and clearing comparison. + useEffect(() => { + // Use selected IDs directly from state so it works even if File objects aren't loaded yet + const selectedIds = (fileState.ui.selectedFileIds as FileId[]) ?? []; + + // Determine next base: keep current if still selected; otherwise use the first selected id + const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId) + ? (params.baseFileId as FileId) + : (selectedIds[0] ?? null); + + // Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id + let nextComp: FileId | null = null; + if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) { + nextComp = params.comparisonFileId as FileId; + } else { + nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null; + } + + if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) { + base.params.setParameters(prev => ({ + ...prev, + baseFileId: nextBase, + comparisonFileId: nextComp, + })); + } + }, [fileState.ui.selectedFileIds, base.params, params.baseFileId, params.comparisonFileId]); + + // Track workbench data and drive loading/result state transitions + const lastProcessedAtRef = useRef(null); + const lastWorkbenchDataRef = useRef(null); + + const updateWorkbenchData = useCallback( + (data: CompareWorkbenchData) => { + const previous = lastWorkbenchDataRef.current; + if ( + previous && + previous.result === data.result && + previous.baseFileId === data.baseFileId && + previous.comparisonFileId === data.comparisonFileId && + previous.isLoading === data.isLoading && + previous.baseLocalFile === data.baseLocalFile && + previous.comparisonLocalFile === data.comparisonLocalFile + ) { + return; + } + lastWorkbenchDataRef.current = data; + setCustomWorkbenchViewData(CUSTOM_VIEW_ID, data); + }, + [setCustomWorkbenchViewData] + ); + + const prepareWorkbenchForRun = useCallback( + ( + baseId: FileId | null, + compId: FileId | null, + options?: { baseFile?: StirlingFile | null; comparisonFile?: StirlingFile | null } + ) => { + if (!baseId || !compId) { + return; + } + + const previous = lastWorkbenchDataRef.current; + const resolvedBaseFile = + options?.baseFile ?? + (baseId ? selectors.getFile(baseId) : null) ?? + previous?.baseLocalFile ?? + null; + const resolvedComparisonFile = + options?.comparisonFile ?? + (compId ? selectors.getFile(compId) : null) ?? + previous?.comparisonLocalFile ?? + null; + + updateWorkbenchData({ + result: null, + baseFileId: baseId, + comparisonFileId: compId, + baseLocalFile: resolvedBaseFile, + comparisonLocalFile: resolvedComparisonFile, + isLoading: true, + }); + + lastProcessedAtRef.current = null; + }, + [selectors, updateWorkbenchData] + ); + + useEffect(() => { + const baseFileId = params.baseFileId as FileId | null; + const comparisonFileId = params.comparisonFileId as FileId | null; + + if (!baseFileId || !comparisonFileId) { + lastProcessedAtRef.current = null; + lastWorkbenchDataRef.current = null; + clearCustomWorkbenchViewData(CUSTOM_VIEW_ID); + return; + } + + const result = operation.result; + const processedAt = result?.totals.processedAt ?? null; + + if ( + result && + processedAt !== null && + processedAt !== lastProcessedAtRef.current && + result.base.fileId === baseFileId && + result.comparison.fileId === comparisonFileId + ) { + const previous = lastWorkbenchDataRef.current; + const baseLocalFile = + (baseFileId ? selectors.getFile(baseFileId) : null) ?? + previous?.baseLocalFile ?? + null; + const comparisonLocalFile = + (comparisonFileId ? selectors.getFile(comparisonFileId) : null) ?? + previous?.comparisonLocalFile ?? + null; + updateWorkbenchData({ + result, + baseFileId, + comparisonFileId, + baseLocalFile, + comparisonLocalFile, + isLoading: false, + }); + lastProcessedAtRef.current = processedAt; + return; + } + + if (base.operation.isLoading) { + const previous = lastWorkbenchDataRef.current; + const baseLocalFile = + (baseFileId ? selectors.getFile(baseFileId) : null) ?? + previous?.baseLocalFile ?? + null; + const comparisonLocalFile = + (comparisonFileId ? selectors.getFile(comparisonFileId) : null) ?? + previous?.comparisonLocalFile ?? + null; + updateWorkbenchData({ + result: null, + baseFileId, + comparisonFileId, + baseLocalFile, + comparisonLocalFile, + isLoading: true, + }); + return; + } + }, [ + base.operation.isLoading, + clearCustomWorkbenchViewData, + operation.result, + params.baseFileId, + params.comparisonFileId, + selectors, + updateWorkbenchData, + ]); + + const handleExecuteCompare = useCallback(async () => { + const baseId = params.baseFileId as FileId | null; + const compId = params.comparisonFileId as FileId | null; + const baseSel = + base.selectedFiles.find((file) => file.fileId === baseId) ?? + (baseId ? selectors.getFile(baseId) : null); + const compSel = + base.selectedFiles.find((file) => file.fileId === compId) ?? + (compId ? selectors.getFile(compId) : null); + const selected: StirlingFile[] = []; + if (baseSel) selected.push(baseSel); + if (compSel) selected.push(compSel); + + prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null }); + if (baseId && compId) { + requestAnimationFrame(() => { + navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); + }); + } + + await operation.executeOperation( + { ...params }, + selected + ); + }, [base.selectedFiles, navigationActions, operation, params, prepareWorkbenchForRun, selectors]); + + // Run compare with explicit ids (used after swap so we don't depend on async state propagation) + const runCompareWithIds = useCallback(async (baseId: FileId | null, compId: FileId | null) => { + const nextParams = { ...params, baseFileId: baseId, comparisonFileId: compId }; + const selected: StirlingFile[] = []; + const baseSel = + base.selectedFiles.find((file) => file.fileId === baseId) ?? + (baseId ? selectors.getFile(baseId) : null); + const compSel = + base.selectedFiles.find((file) => file.fileId === compId) ?? + (compId ? selectors.getFile(compId) : null); + if (baseSel) selected.push(baseSel); + if (compSel) selected.push(compSel); + prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null }); + await operation.executeOperation(nextParams, selected); + }, [base.selectedFiles, operation, params, prepareWorkbenchForRun, selectors]); + + const performSwap = useCallback(() => { + const baseId = params.baseFileId as FileId | null; + const compId = params.comparisonFileId as FileId | null; + if (!baseId || !compId) return; + base.params.setParameters((prev) => ({ + ...prev, + baseFileId: compId, + comparisonFileId: baseId, + })); + if (operation.result) { + runCompareWithIds(compId, baseId); + } + }, [base.params, operation.result, params.baseFileId, params.comparisonFileId, runCompareWithIds]); + + // No custom handler; rely on global add flow which auto-selects added files + + const handleSwap = useCallback(() => { + const baseId = params.baseFileId as FileId | null; + const compId = params.comparisonFileId as FileId | null; + if (!baseId || !compId) return; + if (operation.result) { + setSwapConfirmOpen(true); + return; + } + performSwap(); + }, [operation.result, params.baseFileId, params.comparisonFileId, performSwap]); + + const renderSelectedFile = useCallback( + (role: 'base' | 'comparison') => { + const fileId = role === 'base' ? params.baseFileId : params.comparisonFileId; + const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined; + + // Show add button in base if no base file, or in comparison if base exists but no comparison + const shouldShowAddButton = + (role === 'base' && !params.baseFileId) || + (role === 'comparison' && params.baseFileId && !params.comparisonFileId); + + if (!stub) { + return ( + + openFilesModal({}) : undefined} + > + + {t( + role === 'base' ? 'compare.original.placeholder' : 'compare.edited.placeholder', + role === 'base' ? 'Select the original PDF' : 'Select the edited PDF' + )} + + {shouldShowAddButton && ( + { + e.stopPropagation(); + openFilesModal({}); + }} + style={{ flexShrink: 0 }} + > + + + )} + + + ); + } + // Build compact meta line for pages and date + const dateMs = (stub?.lastModified || stub?.createdAt) ?? null; + const dateText = dateMs + ? new Date(dateMs).toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' }) + : ''; + const pageCount = stub?.processedFile?.totalPages || null; + + return ( + + + + + + + + + {pageCount && dateText && ( + <> + + {pageCount} {t('compare.pages', 'pages')} +
+ {dateText} +
+ + )} +
+
+
+
+ ); + }, + [params.baseFileId, params.comparisonFileId, selectors, t, openFilesModal] + ); + + const baseStub = params.baseFileId ? selectors.getStirlingFileStub(params.baseFileId) : undefined; + const compStub = params.comparisonFileId ? selectors.getStirlingFileStub(params.comparisonFileId) : undefined; + const canExecute = Boolean( + params.baseFileId && + params.comparisonFileId && + params.baseFileId !== params.comparisonFileId && + baseStub && + compStub && + !base.operation.isLoading && + base.endpointEnabled !== false + ); + + const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId); + const hasAnyFiles = selectors.getFiles().length > 0; + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: false, + }, + steps: [ + { + title: t('compare.selection.originalEditedTitle', 'Select Original and Edited PDFs'), + isVisible: true, + content: ( + + {/* Header row: Original PDF + Clear selected aligned to swap column */} + + {t('compare.original.label', 'Original PDF')} + + + + {renderSelectedFile('base')} +
+ {/* Edited PDF section header */} + {t('compare.edited.label', 'Edited PDF')} + {renderSelectedFile('comparison')} + + {hasBothSelected && ( + + + + )} + setSwapConfirmOpen(false)} + title={t('compare.swap.confirmTitle', 'Re-run comparison?')} + centered + size="sm" + > + + {t('compare.swap.confirmBody', 'This will rerun the tool. Are you sure you want to swap the order of Original and Edited?')} + + + + + + + setClearConfirmOpen(false)} + title={t('compare.clear.confirmTitle', 'Clear selected PDFs?')} + centered + size="sm" + > + + {t('compare.clear.confirmBody', 'This will close the current comparison and take you back to Active Files.')} + + + + + + + + ), + }, + ], + executeButton: { + text: t('compare.cta', 'Compare'), + loadingText: t('compare.loading', 'Comparing...'), + onClick: handleExecuteCompare, + disabled: !canExecute, + testId: 'compare-execute', + }, + review: { + isVisible: false, + operation: base.operation, + title: t('compare.review.title', 'Comparison Result'), + onUndo: base.operation.undoOperation, + }, + }); +}; + +const CompareTool = Compare as ToolComponent; +CompareTool.tool = () => useCompareOperation; +CompareTool.getDefaultParameters = () => ({ ...compareDefaultParameters }); + +export default CompareTool; + + + diff --git a/frontend/src/core/tools/ValidateSignature.tsx b/frontend/src/core/tools/ValidateSignature.tsx index 5ad21c83de..30fab68d99 100644 --- a/frontend/src/core/tools/ValidateSignature.tsx +++ b/frontend/src/core/tools/ValidateSignature.tsx @@ -39,6 +39,8 @@ const ValidateSignature = (props: BaseToolProps) => { const hasResults = operation.results.length > 0; const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage; + + useEffect(() => { registerCustomWorkbenchView({ id: REPORT_VIEW_ID, diff --git a/frontend/src/core/types/compare.ts b/frontend/src/core/types/compare.ts new file mode 100644 index 0000000000..65b6ea12c9 --- /dev/null +++ b/frontend/src/core/types/compare.ts @@ -0,0 +1,314 @@ +import type { FileId } from '@app/types/file'; +import type { StirlingFile } from '@app/types/fileContext'; + +export type CompareDiffTokenType = 'unchanged' | 'removed' | 'added'; + +export interface CompareDiffToken { + type: CompareDiffTokenType; + text: string; +} + +export const REMOVAL_HIGHLIGHT = '#FF3B30'; +export const ADDITION_HIGHLIGHT = '#34C759'; +export const PARAGRAPH_SENTINEL = '\uE000¶'; + +export interface TokenBoundingBox { + left: number; + top: number; + width: number; + height: number; +} + +export interface CompareTokenMetadata { + page: number; + paragraph: number; + bbox: TokenBoundingBox | null; +} + +export interface ComparePageSize { + width: number; + height: number; +} + +export interface CompareDocumentInfo { + fileId: string; + fileName: string; + highlightColor: string; + wordCount: number; + pageSizes: ComparePageSize[]; +} + +export interface CompareParagraph { + page: number; + paragraph: number; + text: string; +} + +export interface CompareFilteredTokenInfo { + token: string; + page: number | null; + paragraph: number | null; + bbox: TokenBoundingBox | null; + hasHighlight: boolean; + metaIndex: number; +} + +export interface CompareChangeSide { + text: string; + page: number | null; + paragraph: number | null; +} + +export interface CompareChange { + id: string; + base: CompareChangeSide | null; + comparison: CompareChangeSide | null; +} + +export interface CompareResultData { + base: CompareDocumentInfo; + comparison: CompareDocumentInfo; + totals: { + added: number; + removed: number; + unchanged: number; + durationMs: number; + processedAt: number; + }; + tokens: CompareDiffToken[]; + tokenMetadata: { + base: CompareTokenMetadata[]; + comparison: CompareTokenMetadata[]; + }; + filteredTokenData: { + base: CompareFilteredTokenInfo[]; + comparison: CompareFilteredTokenInfo[]; + }; + sourceTokens: { + base: string[]; + comparison: string[]; + }; + changes: CompareChange[]; + warnings: string[]; + baseParagraphs: CompareParagraph[]; + comparisonParagraphs: CompareParagraph[]; +} + +export interface CompareWorkerWarnings { + complexMessage?: string; + tooLargeMessage?: string; + emptyTextMessage?: string; + tooDissimilarMessage?: string; +} + +export interface CompareWorkerRequest { + type: 'compare'; + payload: { + baseTokens: string[]; + comparisonTokens: string[]; + warnings: CompareWorkerWarnings; + settings?: { + batchSize?: number; + complexThreshold?: number; + maxWordThreshold?: number; + // Early-stop and runtime controls (optional) + earlyStopEnabled?: boolean; + minJaccardUnigram?: number; + minJaccardBigram?: number; + minTokensForEarlyStop?: number; + sampleLimit?: number; + runtimeMaxProcessedTokens?: number; + runtimeMinUnchangedRatio?: number; + }; + }; +} + +export type CompareWorkerResponse = + | { + type: 'chunk'; + tokens: CompareDiffToken[]; + } + | { + type: 'success'; + stats: { + baseWordCount: number; + comparisonWordCount: number; + durationMs: number; + }; + } + | { + type: 'warning'; + message: string; + } + | { + type: 'error'; + message: string; + code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR'; + }; + +export interface CompareDocumentPaneProps { + pane: 'base' | 'comparison'; + layout: 'side-by-side' | 'stacked'; + scrollRef: React.RefObject; + peerScrollRef: React.RefObject; + handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void; + handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent) => void; + handleWheelOverscroll: (pane: 'base' | 'comparison', event: React.WheelEvent) => void; + onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent) => void; + onTouchMove: (event: React.TouchEvent) => void; + onTouchEnd: (event: React.TouchEvent) => void; + isPanMode: boolean; + zoom: number; + title: string; + dropdownPlaceholder?: React.ReactNode; + changes: Array<{ value: string; label: string; pageNumber?: number }>; + onNavigateChange: (id: string, pageNumber?: number) => void; + isLoading: boolean; + processingMessage: string; + pages: PagePreview[]; + pairedPages: PagePreview[]; + getRowHeightPx: (pageNumber: number) => number; + wordHighlightMap: Map; + metaIndexToGroupId: Map; + documentLabel: string; + pageLabel: string; + altLabel: string; + // Page input/navigation props (optional to keep call sites flexible) + pageInputValue?: string; + onPageInputChange?: (next: string) => void; + maxSharedPages?: number; // min(baseTotal, compTotal) + renderedPageNumbers?: Set; + onVisiblePageChange?: (pane: 'base' | 'comparison', pageNumber: number) => void; +} + +// Import types that are referenced in CompareDocumentPaneProps +export interface PagePreview { + pageNumber: number; + width: number; + height: number; + rotation: number; + url: string | null; +} + +export interface WordHighlightEntry { + rect: TokenBoundingBox; + metaIndex: number; +} + +export interface NavigationDropdownProps { + changes: Array<{ value: string; label: string; pageNumber?: number }>; + placeholder: React.ReactNode; + className?: string; + onNavigate: (value: string, pageNumber?: number) => void; + // Optional: pages that currently have previews rendered (1-based page numbers) + renderedPageNumbers?: Set; +} + +// Pan/Zoom and Compare Workbench shared types (moved out of hooks for reuse) +import type React from 'react'; + +export type ComparePane = 'base' | 'comparison'; + +export interface PanState { + x: number; + y: number; +} + +export interface ScrollLinkDelta { + vertical: number; + horizontal: number; +} + +export interface ScrollLinkAnchors { + deltaPixelsBaseToComp: number; + deltaPixelsCompToBase: number; +} + +export interface PanDragState { + active: boolean; + source: ComparePane | null; + startX: number; + startY: number; + startPanX: number; + startPanY: number; + targetStartPanX: number; + targetStartPanY: number; +} + +export interface PinchState { + active: boolean; + pane: ComparePane | null; + startDistance: number; + startZoom: number; +} + +export interface UseComparePanZoomOptions { + prefersStacked: boolean; + basePages: PagePreview[]; + comparisonPages: PagePreview[]; +} + +export interface UseComparePanZoomReturn { + layout: 'side-by-side' | 'stacked'; + setLayout: (layout: 'side-by-side' | 'stacked') => void; + toggleLayout: () => void; + baseScrollRef: React.RefObject; + comparisonScrollRef: React.RefObject; + isScrollLinked: boolean; + setIsScrollLinked: (value: boolean) => void; + captureScrollLinkDelta: () => void; + clearScrollLinkDelta: () => void; + isPanMode: boolean; + setIsPanMode: (value: boolean) => void; + baseZoom: number; + setBaseZoom: (value: number) => void; + comparisonZoom: number; + setComparisonZoom: (value: number) => void; + basePan: PanState; + comparisonPan: PanState; + setPanToTopLeft: (pane: ComparePane) => void; + centerPanForZoom: (pane: ComparePane, zoom: number) => void; + clampPanForZoom: (pane: ComparePane, zoom: number) => void; + handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void; + beginPan: (pane: ComparePane, event: React.MouseEvent) => void; + continuePan: (event: React.MouseEvent) => void; + endPan: () => void; + handleWheelZoom: (pane: ComparePane, event: React.WheelEvent) => void; + handleWheelOverscroll: (pane: ComparePane, event: React.WheelEvent) => void; + onTouchStart: (pane: ComparePane, event: React.TouchEvent) => void; + onTouchMove: (event: React.TouchEvent) => void; + onTouchEnd: () => void; + zoomLimits: { min: number; max: number; step: number }; +} + +export interface PagePreview { + pageNumber: number; + width: number; + height: number; + rotation: number; + url: string | null; +} + +export interface WordHighlightEntry { + rect: TokenBoundingBox; + metaIndex: number; +} + +// Removed legacy upload section types; upload flow now uses the standard active files workbench + +export interface CompareWorkbenchData { + result: CompareResultData | null; + baseFileId: FileId | null; + comparisonFileId: FileId | null; + onSelectBase?: (fileId: FileId | null) => void; + onSelectComparison?: (fileId: FileId | null) => void; + isLoading?: boolean; + baseLocalFile?: StirlingFile | null; + comparisonLocalFile?: StirlingFile | null; +} + +export interface CompareChangeOption { + value: string; + label: string; + pageNumber: number; +} \ No newline at end of file diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index 2baf7586b6..25c1870205 100644 --- a/frontend/src/core/utils/signatureFlattening.ts +++ b/frontend/src/core/utils/signatureFlattening.ts @@ -86,7 +86,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr } } - // Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations) + // Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations) if (!exportActions) { console.error('No export actions available'); return null; diff --git a/frontend/src/core/utils/textDiff.ts b/frontend/src/core/utils/textDiff.ts new file mode 100644 index 0000000000..fcf36a7d01 --- /dev/null +++ b/frontend/src/core/utils/textDiff.ts @@ -0,0 +1,50 @@ +// Shared text diff and normalization utilities for compare tool + +export const shouldConcatWithoutSpace = (word: string) => { + return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s"; +}; + +export const appendWord = (existing: string, word: string) => { + if (!existing) return word; + if (shouldConcatWithoutSpace(word)) return `${existing}${word}`; + return `${existing} ${word}`; +}; +export const tokenize = (text: string): string[] => text.split(/\s+/).filter(Boolean); + +type TokenType = 'unchanged' | 'removed' | 'added'; +export interface LocalToken { type: TokenType; text: string } + +const buildLcsMatrix = (a: string[], b: string[]) => { + const rows = a.length + 1; + const cols = b.length + 1; + const m: number[][] = new Array(rows); + for (let i = 0; i < rows; i += 1) m[i] = new Array(cols).fill(0); + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + m[i][j] = a[i - 1] === b[j - 1] ? m[i - 1][j - 1] + 1 : Math.max(m[i][j - 1], m[i - 1][j]); + } + } + return m; +}; + +export const diffWords = (a: string[], b: string[]): LocalToken[] => { + const matrix = buildLcsMatrix(a, b); + const tokens: LocalToken[] = []; + let i = a.length; + let j = b.length; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + tokens.unshift({ type: 'unchanged', text: a[i - 1] }); + i -= 1; j -= 1; + } else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) { + tokens.unshift({ type: 'added', text: b[j - 1] }); + j -= 1; + } else if (i > 0) { + tokens.unshift({ type: 'removed', text: a[i - 1] }); + i -= 1; + } + } + return tokens; +}; + + diff --git a/frontend/src/core/workers/compareWorker.ts b/frontend/src/core/workers/compareWorker.ts new file mode 100644 index 0000000000..0e3a323e1c --- /dev/null +++ b/frontend/src/core/workers/compareWorker.ts @@ -0,0 +1,452 @@ +/// + +import type { + CompareDiffToken, + CompareWorkerRequest, + CompareWorkerResponse, +} from '@app/types/compare'; + +declare const self: DedicatedWorkerGlobalScope; + +const DEFAULT_SETTINGS = { + batchSize: 5000, + complexThreshold: 25000, + maxWordThreshold: 60000, + // Early stop configuration + earlyStopEnabled: true, + // Jaccard thresholds for quick prefilter (unigram/bigram) + minJaccardUnigram: 0.005, + minJaccardBigram: 0.003, + // Only consider early stop when docs are reasonably large + minTokensForEarlyStop: 20000, + // Sampling cap for similarity estimation + sampleLimit: 50000, + // Runtime stop-loss during chunked diff + runtimeMaxProcessedTokens: 150000, + runtimeMinUnchangedRatio: 0.001, +}; + +const buildMatrix = (words1: string[], words2: string[]) => { + const rows = words1.length + 1; + const cols = words2.length + 1; + const matrix: number[][] = new Array(rows); + + for (let i = 0; i < rows; i += 1) { + matrix[i] = new Array(cols).fill(0); + } + + for (let i = 1; i <= words1.length; i += 1) { + for (let j = 1; j <= words2.length; j += 1) { + matrix[i][j] = + words1[i - 1] === words2[j - 1] + ? matrix[i - 1][j - 1] + 1 + : Math.max(matrix[i][j - 1], matrix[i - 1][j]); + } + } + + return matrix; +}; + +const backtrack = (matrix: number[][], words1: string[], words2: string[]): CompareDiffToken[] => { + const tokens: CompareDiffToken[] = []; + let i = words1.length; + let j = words2.length; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && words1[i - 1] === words2[j - 1]) { + tokens.unshift({ type: 'unchanged', text: words1[i - 1] }); + i -= 1; + j -= 1; + } else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) { + tokens.unshift({ type: 'added', text: words2[j - 1] }); + j -= 1; + } else if (i > 0) { + tokens.unshift({ type: 'removed', text: words1[i - 1] }); + i -= 1; + } else { + j -= 1; + } + } + + return tokens; +}; + +const diff = (words1: string[], words2: string[]): CompareDiffToken[] => { + if (words1.length === 0 && words2.length === 0) { + return []; + } + + const matrix = buildMatrix(words1, words2); + return backtrack(matrix, words1, words2); +}; + +const countBaseTokens = (segment: CompareDiffToken[]) => + segment.reduce((acc, token) => acc + (token.type !== 'added' ? 1 : 0), 0); + +const countComparisonTokens = (segment: CompareDiffToken[]) => + segment.reduce((acc, token) => acc + (token.type !== 'removed' ? 1 : 0), 0); + +const findLastUnchangedIndex = (segment: CompareDiffToken[]) => { + for (let i = segment.length - 1; i >= 0; i -= 1) { + if (segment[i].type === 'unchanged') { + return i; + } + } + return -1; +}; + +const chunkedDiff = ( + words1: string[], + words2: string[], + chunkSize: number, + emit: (tokens: CompareDiffToken[]) => void, + runtimeStop?: { maxProcessedTokens: number; minUnchangedRatio: number } +) => { + if (words1.length === 0 && words2.length === 0) { + return; + } + + const baseChunkSize = Math.max(1, chunkSize); + let dynamicChunkSize = baseChunkSize; + const baseMaxWindow = Math.max(baseChunkSize * 6, baseChunkSize + 512); + let dynamicMaxWindow = baseMaxWindow; + let dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1)); + let dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5)); + let stallIterations = 0; + + const increaseChunkSizes = () => { + const maxChunkSize = baseChunkSize * 8; + if (dynamicChunkSize >= maxChunkSize) { + return; + } + const nextChunk = Math.min( + maxChunkSize, + Math.max(dynamicChunkSize + dynamicStep, Math.floor(dynamicChunkSize * 1.5)) + ); + if (nextChunk === dynamicChunkSize) { + return; + } + dynamicChunkSize = nextChunk; + dynamicMaxWindow = Math.max(dynamicMaxWindow, Math.max(dynamicChunkSize * 6, dynamicChunkSize + 512)); + dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1)); + dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5)); + }; + + let index1 = 0; + let index2 = 0; + let buffer1: string[] = []; + let buffer2: string[] = []; + let totalProcessedBase = 0; + let totalProcessedComp = 0; + let totalUnchanged = 0; + + const countUnchanged = (segment: CompareDiffToken[]) => + segment.reduce((acc, token) => acc + (token.type === 'unchanged' ? 1 : 0), 0); + + const flushRemainder = () => { + if (buffer1.length === 0 && buffer2.length === 0) { + return; + } + const finalTokens = diff(buffer1, buffer2); + if (finalTokens.length > 0) { + emit(finalTokens); + } + buffer1 = []; + buffer2 = []; + index1 = words1.length; + index2 = words2.length; + }; + + while ( + index1 < words1.length || + index2 < words2.length || + buffer1.length > 0 || + buffer2.length > 0 + ) { + const remaining1 = Math.max(0, words1.length - index1); + const remaining2 = Math.max(0, words2.length - index2); + + let windowSize = Math.max(dynamicChunkSize, buffer1.length, buffer2.length); + let window1: string[] = []; + let window2: string[] = []; + let chunkTokens: CompareDiffToken[] = []; + let reachedEnd = false; + + while (true) { + const take1 = Math.min(Math.max(0, windowSize - buffer1.length), remaining1); + const take2 = Math.min(Math.max(0, windowSize - buffer2.length), remaining2); + + const slice1 = take1 > 0 ? words1.slice(index1, index1 + take1) : []; + const slice2 = take2 > 0 ? words2.slice(index2, index2 + take2) : []; + + window1 = buffer1.length > 0 ? [...buffer1, ...slice1] : slice1; + window2 = buffer2.length > 0 ? [...buffer2, ...slice2] : slice2; + + if (window1.length === 0 && window2.length === 0) { + flushRemainder(); + return; + } + + chunkTokens = diff(window1, window2); + const lastStableIndex = findLastUnchangedIndex(chunkTokens); + + reachedEnd = + index1 + take1 >= words1.length && + index2 + take2 >= words2.length; + + const windowTooLarge = + window1.length >= dynamicMaxWindow || + window2.length >= dynamicMaxWindow; + + if (lastStableIndex >= 0 || reachedEnd || windowTooLarge) { + break; + } + + const canGrow1 = take1 < remaining1; + const canGrow2 = take2 < remaining2; + + if (!canGrow1 && !canGrow2) { + break; + } + + windowSize = Math.min( + dynamicMaxWindow, + windowSize + dynamicStep + ); + } + + if (chunkTokens.length === 0) { + if (reachedEnd) { + flushRemainder(); + return; + } + windowSize = Math.min(windowSize + dynamicStep, dynamicMaxWindow); + stallIterations += 1; + if (stallIterations >= 3) { + increaseChunkSizes(); + stallIterations = 0; + } + continue; + } + + let commitIndex = reachedEnd ? chunkTokens.length - 1 : findLastUnchangedIndex(chunkTokens); + if (commitIndex < 0) { + commitIndex = reachedEnd + ? chunkTokens.length - 1 + : Math.min(chunkTokens.length - 1, dynamicMinCommit - 1); + } + + const commitTokens = commitIndex >= 0 ? chunkTokens.slice(0, commitIndex + 1) : []; + const baseConsumed = countBaseTokens(commitTokens); + const comparisonConsumed = countComparisonTokens(commitTokens); + + if (commitTokens.length > 0) { + emit(commitTokens); + } + + const consumedFromNew1 = Math.max(0, baseConsumed - buffer1.length); + const consumedFromNew2 = Math.max(0, comparisonConsumed - buffer2.length); + + index1 += consumedFromNew1; + index2 += consumedFromNew2; + + buffer1 = window1.slice(baseConsumed); + buffer2 = window2.slice(comparisonConsumed); + // Update runtime counters and early stop if necessary + totalProcessedBase += baseConsumed; + totalProcessedComp += comparisonConsumed; + totalUnchanged += countUnchanged(commitTokens); + + if (runtimeStop) { + const processedTotal = totalProcessedBase + totalProcessedComp; + if (processedTotal >= runtimeStop.maxProcessedTokens) { + const unchangedRatio = totalUnchanged / Math.max(1, processedTotal); + if (unchangedRatio < runtimeStop.minUnchangedRatio) { + // Signal early termination for extreme dissimilarity + const err = new Error('EARLY_STOP_TOO_DISSIMILAR'); + (err as Error & { __earlyStop?: boolean }).__earlyStop = true; + throw err; + } + } + } + + if (reachedEnd) { + flushRemainder(); + break; + } + + if (commitTokens.length < dynamicMinCommit) { + stallIterations += 1; + } else { + stallIterations = 0; + } + + if (commitTokens.length === 0 && buffer1.length + buffer2.length > 0) { + if (buffer1.length > 0 && index1 < words1.length) { + buffer1 = buffer1.slice(1); + index1 += 1; + } else if (buffer2.length > 0 && index2 < words2.length) { + buffer2 = buffer2.slice(1); + index2 += 1; + } + } + + if (stallIterations >= 3) { + increaseChunkSizes(); + stallIterations = 0; + } + } + + flushRemainder(); +}; + +// Fast similarity estimation using sampled unigrams and bigrams with Jaccard +const buildSampledSet = (tokens: string[], sampleLimit: number, ngram: 1 | 2): Set => { + const result = new Set(); + if (tokens.length === 0) return result; + const stride = Math.max(1, Math.ceil(tokens.length / sampleLimit)); + if (ngram === 1) { + for (let i = 0; i < tokens.length; i += stride) { + const t = tokens[i]; + if (t) result.add(t); + } + return result; + } + // ngram === 2 + for (let i = 0; i + 1 < tokens.length; i += stride) { + const a = tokens[i]; + const b = tokens[i + 1]; + if (a && b) result.add(`${a}|${b}`); + } + return result; +}; + +const jaccard = (a: Set, b: Set): number => { + if (a.size === 0 && b.size === 0) return 1; + if (a.size === 0 || b.size === 0) return 0; + let intersection = 0; + const smaller = a.size <= b.size ? a : b; + const larger = a.size <= b.size ? b : a; + for (const v of smaller) { + if (larger.has(v)) intersection += 1; + } + const union = a.size + b.size - intersection; + return union > 0 ? intersection / union : 0; +}; + +self.onmessage = (event: MessageEvent) => { + const { data } = event; + if (!data || data.type !== 'compare') { + return; + } + + const { baseTokens, comparisonTokens, warnings, settings } = data.payload; + const { + batchSize = DEFAULT_SETTINGS.batchSize, + complexThreshold = DEFAULT_SETTINGS.complexThreshold, + maxWordThreshold = DEFAULT_SETTINGS.maxWordThreshold, + earlyStopEnabled = DEFAULT_SETTINGS.earlyStopEnabled, + minJaccardUnigram = DEFAULT_SETTINGS.minJaccardUnigram, + minJaccardBigram = DEFAULT_SETTINGS.minJaccardBigram, + minTokensForEarlyStop = DEFAULT_SETTINGS.minTokensForEarlyStop, + sampleLimit = DEFAULT_SETTINGS.sampleLimit, + runtimeMaxProcessedTokens = DEFAULT_SETTINGS.runtimeMaxProcessedTokens, + runtimeMinUnchangedRatio = DEFAULT_SETTINGS.runtimeMinUnchangedRatio, + } = settings ?? {}; + + if (!baseTokens || !comparisonTokens || baseTokens.length === 0 || comparisonTokens.length === 0) { + const response: CompareWorkerResponse = { + type: 'error', + message: warnings.emptyTextMessage ?? 'One or both texts are empty.', + code: 'EMPTY_TEXT', + }; + self.postMessage(response); + return; + } + + if (baseTokens.length > maxWordThreshold || comparisonTokens.length > maxWordThreshold) { + // For compare tool, do not fail hard; warn and continue with chunked diff + const response: CompareWorkerResponse = { + type: 'warning', + message: warnings.tooLargeMessage ?? 'Documents are too large to compare.', + }; + self.postMessage(response); + } + + const isComplex = baseTokens.length > complexThreshold || comparisonTokens.length > complexThreshold; + + if (isComplex && warnings.complexMessage) { + const warningResponse: CompareWorkerResponse = { + type: 'warning', + message: warnings.complexMessage, + }; + self.postMessage(warningResponse); + } + + // Quick prefilter to avoid heavy diff on extremely dissimilar large docs + if (earlyStopEnabled && Math.min(baseTokens.length, comparisonTokens.length) >= minTokensForEarlyStop) { + const set1u = buildSampledSet(baseTokens, sampleLimit, 1); + const set2u = buildSampledSet(comparisonTokens, sampleLimit, 1); + const jUni = jaccard(set1u, set2u); + const set1b = buildSampledSet(baseTokens, sampleLimit, 2); + const set2b = buildSampledSet(comparisonTokens, sampleLimit, 2); + const jBi = jaccard(set1b, set2b); + if (jUni < minJaccardUnigram && jBi < minJaccardBigram) { + const response: CompareWorkerResponse = { + type: 'error', + message: + warnings.tooDissimilarMessage ?? + 'These documents appear highly dissimilar. Comparison was stopped to save time.', + code: 'TOO_DISSIMILAR', + }; + self.postMessage(response); + return; + } + } + + const start = performance.now(); + try { + chunkedDiff( + baseTokens, + comparisonTokens, + batchSize, + (tokens) => { + if (tokens.length === 0) { + return; + } + const response: CompareWorkerResponse = { + type: 'chunk', + tokens, + }; + self.postMessage(response); + }, + { maxProcessedTokens: runtimeMaxProcessedTokens, minUnchangedRatio: runtimeMinUnchangedRatio } + ); + } catch (err) { + const error = err as Error & { __earlyStop?: boolean }; + if (error && (error.__earlyStop || error.message === 'EARLY_STOP_TOO_DISSIMILAR')) { + const response: CompareWorkerResponse = { + type: 'error', + message: + warnings.tooDissimilarMessage ?? + 'These documents appear highly dissimilar. Comparison was stopped to save time.', + code: 'TOO_DISSIMILAR', + }; + self.postMessage(response); + return; + } + throw err; + } + const durationMs = performance.now() - start; + + const response: CompareWorkerResponse = { + type: 'success', + stats: { + baseWordCount: baseTokens.length, + comparisonWordCount: comparisonTokens.length, + durationMs, + }, + }; + + self.postMessage(response); +}; diff --git a/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts b/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts index 270188bc8b..da03a72158 100644 --- a/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts +++ b/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts @@ -1,11 +1,11 @@ -import { useMediaQuery } from '@mantine/hooks'; import { usePreferences } from '@app/contexts/PreferencesContext'; import { useAuth } from '@app/auth/UseSession'; +import { useIsMobile } from '@app/hooks/useIsMobile'; export function useShouldShowWelcomeModal(): boolean { const { preferences } = usePreferences(); const { session, loading } = useAuth(); - const isMobile = useMediaQuery("(max-width: 1024px)"); + const isMobile = useIsMobile(); // Only show welcome modal if user is authenticated (session exists) // This prevents the modal from showing on login screens when security is enabled From c8615518a6031cf66e532e2fbda09ffcc0a17bcc Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:02:43 +0000 Subject: [PATCH 03/16] Addition of the Show JavaScript tool (#4877) # Description of Changes - Added the show javascript tool. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### 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. --- .../public/locales/en-GB/translation.json | 17 +- .../public/locales/en-US/translation.json | 17 +- .../core/components/fileEditor/FileEditor.tsx | 51 ++- .../shared/filePreview/DocumentThumbnail.tsx | 9 +- .../shared/filePreview/getFileTypeIcon.tsx | 32 ++ .../tools/shared/ReviewToolStep.tsx | 42 +- .../tools/shared/createToolFlow.tsx | 15 +- .../components/tools/showJS/ShowJSView.css | 131 ++++++ .../components/tools/showJS/ShowJSView.tsx | 305 ++++++++++++++ .../src/core/components/tools/showJS/utils.ts | 382 ++++++++++++++++++ .../core/data/useTranslatedToolRegistry.tsx | 5 +- .../hooks/tools/showJS/useShowJSOperation.ts | 137 +++++++ .../hooks/tools/showJS/useShowJSParameters.ts | 21 + frontend/src/core/styles/theme.css | 10 + frontend/src/core/tools/ShowJS.tsx | 150 +++++++ 15 files changed, 1281 insertions(+), 43 deletions(-) create mode 100644 frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx create mode 100644 frontend/src/core/components/tools/showJS/ShowJSView.css create mode 100644 frontend/src/core/components/tools/showJS/ShowJSView.tsx create mode 100644 frontend/src/core/components/tools/showJS/utils.ts create mode 100644 frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts create mode 100644 frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts create mode 100644 frontend/src/core/tools/ShowJS.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 3e50f5cee8..4e9904f190 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -99,6 +99,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -2753,7 +2755,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact", @@ -4587,6 +4596,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 8c787ee61f..10e923c904 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -98,6 +98,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -3050,7 +3052,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,manual", @@ -4909,6 +4918,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index fbafa8b896..76e6bb1a26 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -14,6 +14,7 @@ import { FileId, StirlingFile } from '@app/types/fileContext'; import { alert } from '@app/components/toast'; import { downloadBlob } from '@app/utils/downloadUtils'; import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; interface FileEditorProps { @@ -65,6 +66,15 @@ const FileEditor = ({ }, []); const [selectionMode, setSelectionMode] = useState(toolMode); + // Current tool (for enforcing maxFiles limits) + const { selectedTool } = useToolWorkflow(); + + // Compute effective max allowed files based on the active tool and mode + const maxAllowed = useMemo(() => { + const rawMax = selectedTool?.maxFiles; + return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; + }, [selectedTool?.maxFiles, toolMode]); + // Enable selection mode automatically in tool mode useEffect(() => { if (toolMode) { @@ -83,7 +93,10 @@ const FileEditor = ({ const localSelectedIds = contextSelectedIds; const handleSelectAllFiles = useCallback(() => { - setSelectedFiles(state.files.ids); + // Respect maxAllowed: if limited, select the last N files + const allIds = state.files.ids; + const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds; + setSelectedFiles(idsToSelect); try { clearAllFileErrors(); } catch (error) { @@ -91,7 +104,7 @@ const FileEditor = ({ console.warn('Failed to clear file errors on select all:', error); } } - }, [state.files.ids, setSelectedFiles, clearAllFileErrors]); + }, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]); const handleDeselectAllFiles = useCallback(() => { setSelectedFiles([]); @@ -131,6 +144,13 @@ const FileEditor = ({ // - HTML ZIPs stay intact // - Non-ZIP files pass through unchanged await addFiles(uploadedFiles, { selectFiles: true }); + // After auto-selection, enforce maxAllowed if needed + if (Number.isFinite(maxAllowed)) { + const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id); + if (nowSelectedIds.length > maxAllowed) { + setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); + } + } showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } } catch (err) { @@ -138,7 +158,7 @@ const FileEditor = ({ showError(errorMessage); console.error('File processing error:', err); } - }, [addFiles, showStatus, showError]); + }, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; @@ -156,24 +176,33 @@ const FileEditor = ({ newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection - // In tool mode, typically allow multiple files unless specified otherwise - const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools + // Determine max files allowed from the active tool (negative or undefined means unlimited) + const rawMax = selectedTool?.maxFiles; + const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; if (maxAllowed === 1) { + // Only one file allowed -> replace selection with the new file newSelection = [contextFileId]; } else { - // Check if we've hit the selection limit - if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); - return; + // If at capacity, drop the oldest selected and append the new one + if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) { + newSelection = [...currentSelectedIds.slice(1), contextFileId]; + } else { + newSelection = [...currentSelectedIds, contextFileId]; } - newSelection = [...currentSelectedIds, contextFileId]; } } // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); + }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]); + + // Enforce maxAllowed when tool changes or when an external action sets too many selected files + useEffect(() => { + if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) { + setSelectedFiles(selectedFileIds.slice(-maxAllowed)); + } + }, [maxAllowed, selectedFileIds, setSelectedFiles]); // File reordering handler for drag and drop diff --git a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx index 5ace7d03f0..b60efa208b 100644 --- a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Center, Image } from '@mantine/core'; -import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import { getFileTypeIcon } from '@app/components/shared/filePreview/getFileTypeIcon'; import { StirlingFileStub } from '@app/types/fileContext'; import { PrivateContent } from '@app/components/shared/PrivateContent'; @@ -53,12 +53,7 @@ const DocumentThumbnail: React.FC = ({
- + {getFileTypeIcon(file)}
{children} diff --git a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx new file mode 100644 index 0000000000..afcacad070 --- /dev/null +++ b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import JavascriptIcon from "@mui/icons-material/Javascript"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { detectFileExtension } from "@app/utils/fileUtils"; + +type FileLike = File | StirlingFileStub; + +/** + * Returns an appropriate file type icon for the provided file. + * - Uses the real file type and extension to decide the icon. + * - No any-casts; accepts File or StirlingFileStub. + */ +export function getFileTypeIcon(file: FileLike, size: number | string = "2rem"): React.ReactElement { + const name = (file?.name ?? "").toLowerCase(); + const mime = (file?.type ?? "").toLowerCase(); + const ext = detectFileExtension(name); + + // JavaScript + if (ext === "js" || mime.includes("javascript")) { + return ; + } + + // PDF + if (ext === "pdf" || mime === "application/pdf") { + return ; + } + + // Fallback generic + return ; +} diff --git a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx index 30c8c906e0..2314a63025 100644 --- a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx @@ -14,7 +14,7 @@ export interface ReviewToolStepProps { operation: ToolOperationHook; title?: string; onFileClick?: (file: File) => void; - onUndo: () => void; + onUndo?: () => void; isCollapsed?: boolean; onCollapsedClick?: () => void; } @@ -26,14 +26,14 @@ function ReviewStepContent({ }: { operation: ToolOperationHook; onFileClick?: (file: File) => void; - onUndo: () => void; + onUndo?: () => void; }) { const { t } = useTranslation(); const stepRef = useRef(null); const handleUndo = async () => { try { - onUndo(); + onUndo?.(); } catch (error) { // Error is already handled by useToolOperation, just reset loading state console.error("Undo operation failed:", error); @@ -73,17 +73,19 @@ function ReviewStepContent({ /> )} - - - + {onUndo && ( + + + + )} {operation.downloadUrl && ( + + +
+ +
+
+ {lines.map((tokens, ln) => { + if (isHidden(ln)) return null; + const end = startToEnd.get(ln); + const folded = end != null && collapsed.has(ln); + let pos = 0; + const lineMatches = matches.map((m, idx) => ({ ...m, idx })).filter((m) => m.line === ln); + const content: React.ReactNode[] = []; + tokens.forEach((tok, ti) => { + const textSeg = tok.text; + const tokenStart = pos; + const tokenEnd = pos + textSeg.length; + + if (!query || lineMatches.length === 0) { + const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`; + content.push( + + {textSeg} + , + ); + pos = tokenEnd; + return; + } + + // Collect matches that intersect this token + const matchesInToken = lineMatches + .filter((m) => m.start < tokenEnd && m.end > tokenStart) + .sort((a, b) => a.start - b.start); + + if (matchesInToken.length === 0) { + const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`; + content.push( + + {textSeg} + , + ); + pos = tokenEnd; + return; + } + + let cursor = 0; + const tokenCls = tok.type === "plain" ? "" : `tok-${tok.type}`; + + matchesInToken.forEach((m, mi) => { + const localStart = Math.max(0, m.start - tokenStart); + const localEnd = Math.min(textSeg.length, m.end - tokenStart); + + // before match + if (localStart > cursor) { + const beforeText = textSeg.slice(cursor, localStart); + const cls = tokenCls || undefined; + content.push( + + {beforeText} + , + ); + } + // matched piece + const hitText = textSeg.slice(localStart, localEnd); + const hitCls = + ["search-hit", m.idx === active ? "search-hit-active" : "", tokenCls].filter(Boolean).join(" ") || + undefined; + content.push( + + {hitText} + , + ); + cursor = localEnd; + }); + + // tail after last match + if (cursor < textSeg.length) { + const tailText = textSeg.slice(cursor); + const cls = tokenCls || undefined; + content.push( + + {tailText} + , + ); + } + + pos = tokenEnd; + }); + return ( +
+
+ {end != null ? ( + + ) : ( + + )} + {ln + 1} +
+
+ {content} + {folded && {"{...}"}} +
+
+ ); + })} +
+
+
+
+ + ); +}; + +export default ShowJSView; diff --git a/frontend/src/core/components/tools/showJS/utils.ts b/frontend/src/core/components/tools/showJS/utils.ts new file mode 100644 index 0000000000..c5cca24884 --- /dev/null +++ b/frontend/src/core/components/tools/showJS/utils.ts @@ -0,0 +1,382 @@ +export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain"; +export type ShowJsToken = { type: ShowJsTokenType; text: string }; + +const JS_KEYWORDS = new Set([ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "extends", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "let", + "new", + "return", + "super", + "switch", + "this", + "throw", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + "await", + "of", +]); + +export function tokenizeToLines(src: string, keywords: Set = JS_KEYWORDS): ShowJsToken[][] { + const lines: ShowJsToken[][] = []; + let current: ShowJsToken[] = []; + let i = 0; + let inBlockCom = false; + let inLineCom = false; + let inString: '"' | "'" | "`" | null = null; + let escaped = false; + + const push = (type: ShowJsTokenType, s: string) => { + if (s) { + current.push({ type, text: s }); + } + }; + + // Named actions for readability + const advance = (n: number = 1) => { + i += n; + }; + const handleNewline = () => { + lines.push(current); + current = []; + inLineCom = false; + advance(); + }; + const handleInLineCommentChar = (ch: string) => { + push("com", ch); + advance(); + }; + const handleBlockCommentEnd = () => { + push("com", "*/"); + inBlockCom = false; + advance(2); + }; + const handleInBlockCommentChar = (ch: string) => { + push("com", ch); + advance(); + }; + const handleInStringChar = (ch: string) => { + push("str", ch); + if (!escaped) { + const isEscape = ch === "\\"; + const isStringClose = ch === inString; + if (isEscape) { + escaped = true; + } else if (isStringClose) { + inString = null; + } + } else { + escaped = false; + } + advance(); + }; + const startLineComment = () => { + push("com", "//"); + inLineCom = true; + advance(2); + }; + const startBlockComment = () => { + push("com", "/*"); + inBlockCom = true; + advance(2); + }; + const startString = (ch: '"' | "'" | "`") => { + inString = ch; + push("str", ch); + advance(); + }; + const pushNumberToken = () => { + let j = i + 1; + const isNumberContinuation = (c: string) => /[0-9._xobA-Fa-f]/.test(c); + while (j < src.length && isNumberContinuation(src[j])) { + j++; + } + push("num", src.slice(i, j)); + i = j; + }; + const pushIdentifierToken = () => { + let j = i + 1; + const isIdentContinuation = (c: string) => /[A-Za-z0-9_$]/.test(c); + while (j < src.length && isIdentContinuation(src[j])) { + j++; + } + const id = src.slice(i, j); + const isKeyword = keywords.has(id); + push(isKeyword ? "kw" : "plain", id); + i = j; + }; + const pushPlainChar = (ch: string) => { + push("plain", ch); + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const next = src[i + 1]; + + // Named conditions + const isNewline = ch === "\n"; + const isLineCommentStart = ch === "/" && next === "/"; + const isBlockCommentStart = ch === "/" && next === "*"; + const isStringDelimiter = ch === "'" || ch === '"' || ch === "`"; + const isDigit = /[0-9]/.test(ch); + const isIdentifierStart = /[A-Za-z_$]/.test(ch); + + if (isNewline) { + handleNewline(); + continue; + } + + if (inLineCom) { + handleInLineCommentChar(ch); + continue; + } + + if (inBlockCom) { + const isBlockCommentEnd = ch === "*" && next === "/"; + if (isBlockCommentEnd) { + handleBlockCommentEnd(); + continue; + } + handleInBlockCommentChar(ch); + continue; + } + + if (inString) { + handleInStringChar(ch); + continue; + } + + if (isLineCommentStart) { + startLineComment(); + continue; + } + + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + + if (isStringDelimiter) { + startString(ch as '"' | "'" | "`"); + continue; + } + + if (isDigit) { + pushNumberToken(); + continue; + } + + if (isIdentifierStart) { + pushIdentifierToken(); + continue; + } + + pushPlainChar(ch); + } + + lines.push(current); + return lines; +} + +export function computeBlocks(src: string): Array<{ start: number; end: number }> { + const res: Array<{ start: number; end: number }> = []; + let i = 0; + let line = 0; + let inBlock = false; + let inLine = false; + let str: '"' | "'" | "`" | null = null; + let esc = false; + const stack: number[] = []; + + // Actions + const advance = (n: number = 1) => { + i += n; + }; + const handleNewline = () => { + line++; + inLine = false; + advance(); + }; + const startLineComment = () => { + inLine = true; + advance(2); + }; + const startBlockComment = () => { + inBlock = true; + advance(2); + }; + const endBlockComment = () => { + inBlock = false; + advance(2); + }; + const startString = (delim: '"' | "'" | "`") => { + str = delim; + advance(); + }; + const handleStringChar = (ch: string) => { + if (!esc) { + const isEscape = ch === "\\"; + const isClose = ch === str; + if (isEscape) { + esc = true; + } else if (isClose) { + str = null; + } + } else { + esc = false; + } + advance(); + }; + const pushOpenBrace = () => { + stack.push(line); + advance(); + }; + const handleCloseBrace = () => { + const s = stack.pop(); + if (s != null && line > s) { + res.push({ start: s, end: line }); + } + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const nx = src[i + 1]; + + // Conditions + const isNewline = ch === "\n"; + const isLineCommentStart = ch === "/" && nx === "/"; + const isBlockCommentStart = ch === "/" && nx === "*"; + const isBlockCommentEnd = ch === "*" && nx === "/"; + const isStringDelimiter = ch === "'" || ch === '"' || ch === "`"; + const isOpenBrace = ch === "{"; + const isCloseBrace = ch === "}"; + + if (isNewline) { + handleNewline(); + continue; + } + if (inLine) { + advance(); + continue; + } + if (inBlock) { + if (isBlockCommentEnd) { + endBlockComment(); + } else { + advance(); + } + continue; + } + if (str) { + handleStringChar(ch); + continue; + } + if (isLineCommentStart) { + startLineComment(); + continue; + } + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + if (isStringDelimiter) { + startString(ch as '"' | "'" | "`"); + continue; + } + if (isOpenBrace) { + pushOpenBrace(); + continue; + } + if (isCloseBrace) { + handleCloseBrace(); + continue; + } + advance(); + } + return res; +} + +export function computeSearchMatches( + lines: ShowJsToken[][], + query: string, +): Array<{ line: number; start: number; end: number }> { + if (!query) { + return []; + } + const q = query.toLowerCase(); + const list: Array<{ line: number; start: number; end: number }> = []; + lines.forEach((toks, ln) => { + const raw = toks.map((t) => t.text).join(""); + let idx = 0; + while (true) { + const pos = raw.toLowerCase().indexOf(q, idx); + if (pos === -1) { + break; + } + list.push({ + line: ln, + start: pos, + end: pos + q.length, + }); + idx = pos + Math.max(1, q.length); + } + }); + return list; +} + +export async function copyTextToClipboard(text: string, fallbackElement?: HTMLElement | null): Promise { + try { + if (typeof navigator !== "undefined" && navigator.clipboard) { + await navigator.clipboard.writeText(text || ""); + return true; + } + } catch { + // fall through to fallback + } + if (typeof document === "undefined" || !fallbackElement) return false; + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(fallbackElement); + selection?.removeAllRanges(); + selection?.addRange(range); + try { + document.execCommand("copy"); + return true; + } finally { + selection?.removeAllRanges(); + } +} + +export function triggerDownload(url: string, filename: string): void { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 82c02b2330..bcd11478b7 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -121,6 +121,7 @@ import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlank import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings"; import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings"; import ValidateSignature from "@app/tools/ValidateSignature"; +import ShowJS from "@app/tools/ShowJS"; import Automate from "@app/tools/Automate"; import Compare from "@app/tools/Compare"; import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats"; @@ -714,10 +715,12 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { showJS: { icon: , name: t("home.showJS.title", "Show JavaScript"), - component: null, + component: ShowJS, description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, + maxFiles: 1, + endpoints: ["show-javascript"], synonyms: getSynonyms(t, "showJS"), supportsAutomate: false, automationSettings: null diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts new file mode 100644 index 0000000000..081fc2723c --- /dev/null +++ b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts @@ -0,0 +1,137 @@ +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import apiClient from '@app/services/apiClient'; +import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; +import type { StirlingFile } from '@app/types/fileContext'; +import { extractErrorMessage } from '@app/utils/toolErrorHandler'; +import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters'; +import type { ResponseType } from 'axios'; + +export interface ShowJSOperationHook extends ToolOperationHook { + scriptText: string | null; +} + +export const useShowJSOperation = (): ShowJSOperationHook => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [files, setFiles] = useState([]); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [scriptText, setScriptText] = useState(null); + + const cancelRequested = useRef(false); + const previousUrl = useRef(null); + + const cleanupDownloadUrl = useCallback(() => { + if (previousUrl.current) { + URL.revokeObjectURL(previousUrl.current); + previousUrl.current = null; + } + }, []); + + const resetResults = useCallback(() => { + cancelRequested.current = false; + setScriptText(null); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + setStatus(''); + setErrorMessage(null); + }, [cleanupDownloadUrl]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + const executeOperation = useCallback( + async (_params: ShowJSParameters, selectedFiles: StirlingFile[]) => { + if (selectedFiles.length === 0) { + setErrorMessage(t('noFileSelected', 'No files selected')); + return; + } + + cancelRequested.current = false; + setIsLoading(true); + setStatus(t('showJS.processing', 'Extracting JavaScript...')); + setErrorMessage(null); + setScriptText(null); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + + try { + const file = selectedFiles[0]; + const formData = new FormData(); + formData.append('fileInput', file); + + const response = await apiClient.post('/api/v1/misc/show-javascript', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + responseType: 'text' as ResponseType, + transformResponse: [(data) => data], + }); + + const text: string = typeof response.data === 'string' ? response.data : ''; + setScriptText(text); + + // Optional: prepare a downloadable file + const outFile = new File([text], (file.name?.replace(/\.[^.]+$/, '') || 'extracted') + '.js', { + type: 'application/javascript', + }); + setFiles([outFile]); + const blobUrl = URL.createObjectURL(outFile); + previousUrl.current = blobUrl; + setDownloadUrl(blobUrl); + setDownloadFilename(outFile.name); + + setStatus(t('showJS.done', 'JavaScript extracted')); + } catch (error: unknown) { + setErrorMessage(extractErrorMessage(error)); + setStatus(''); + } finally { + setIsLoading(false); + } + }, + [t, cleanupDownloadUrl] + ); + + const cancelOperation = useCallback(() => { + cancelRequested.current = true; + setIsLoading(false); + setStatus(t('operationCancelled', 'Operation cancelled')); + }, [t]); + + const undoOperation = useCallback(async () => { + // No-op for this tool + setStatus(t('nothingToUndo', 'Nothing to undo')); + }, [t]); + + return { + // State (align with ToolOperationHook) + files, + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl, + downloadFilename, + isLoading, + status, + errorMessage, + progress: null, + + // Custom state + scriptText, + + // Actions + executeOperation, + resetResults, + clearError, + cancelOperation, + undoOperation, + }; +}; + + diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts b/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts new file mode 100644 index 0000000000..67bcaca568 --- /dev/null +++ b/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts @@ -0,0 +1,21 @@ +import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; +import { BaseParameters } from '@app/types/parameters'; + +export interface ShowJSParameters extends BaseParameters { + // Extends BaseParameters - ready for future parameter additions if needed +} + +export const defaultParameters: ShowJSParameters = { + // No parameters needed +}; + + +export type ShowJSParametersHook = BaseParametersHook; + +export const useShowJSParameters = (): ShowJSParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'show-javascript', + }); +}; + diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index 22c90d090a..3051ced06e 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -302,6 +302,11 @@ --pdf-light-simulated-page-bg: 255 255 255; --pdf-light-simulated-page-text: 15 23 42; + /* Code token colors (light mode) */ + --code-kw-color: #1d4ed8; /* blue-700 */ + --code-str-color: #16a34a; /* green-600 */ + --code-num-color: #4338ca; /* indigo-700 */ + --code-com-color: #6b7280; /* gray-500 */ /* Compare tool specific colors - only for colors that don't have existing theme pairs */ --compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45); --compare-upload-dropzone-border: rgba(148, 163, 184, 0.6); @@ -534,6 +539,11 @@ --modal-content-bg: #2A2F36; --modal-header-border: rgba(255, 255, 255, 0.08); + /* Code token colors (dark mode - Cursor-like) */ + --code-kw-color: #C792EA; /* purple */ + --code-str-color: #C3E88D; /* green */ + --code-num-color: #F78C6C; /* orange */ + --code-com-color: #697098; /* muted gray-blue */ /* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */ --compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45); --compare-upload-dropzone-border: rgba(75, 85, 99, 0.6); diff --git a/frontend/src/core/tools/ShowJS.tsx b/frontend/src/core/tools/ShowJS.tsx new file mode 100644 index 0000000000..234298d9b6 --- /dev/null +++ b/frontend/src/core/tools/ShowJS.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; +import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; +import type { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { useShowJSParameters, defaultParameters } from '@app/hooks/tools/showJS/useShowJSParameters'; +import { useShowJSOperation, type ShowJSOperationHook } from '@app/hooks/tools/showJS/useShowJSOperation'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import ShowJSView from '@app/components/tools/showJS/ShowJSView'; +import { useFileSelection } from '@app/contexts/file/fileHooks'; + +const ShowJS = (props: BaseToolProps) => { + const { t } = useTranslation(); + const { actions: navigationActions } = useNavigationActions(); + const navigationState = useNavigationState(); + + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + } = useToolWorkflow(); + + const VIEW_ID = 'showJSView'; + const WORKBENCH_ID = 'custom:showJS' as const; + const viewIcon = useMemo(() => , []); + + const base = useBaseTool('showJS', useShowJSParameters, useShowJSOperation, props, { minFiles: 1 }); + const operation = base.operation as ShowJSOperationHook; + const hasResults = Boolean(operation.scriptText); + const { clearSelections } = useFileSelection(); + + useEffect(() => { + registerCustomWorkbenchView({ + id: VIEW_ID, + workbenchId: WORKBENCH_ID, + label: t('showJS.view.title', 'JavaScript'), + icon: viewIcon, + component: ({ data }) => , + }); + + return () => { + clearCustomWorkbenchViewData(VIEW_ID); + unregisterCustomWorkbenchView(VIEW_ID); + }; + }, [clearCustomWorkbenchViewData, registerCustomWorkbenchView, t, unregisterCustomWorkbenchView, viewIcon]); + + const lastShownRef = useRef(null); + + useEffect(() => { + if (operation.scriptText) { + setCustomWorkbenchViewData(VIEW_ID, { + scriptText: operation.scriptText, + downloadUrl: operation.downloadUrl, + downloadFilename: operation.downloadFilename, + }); + const marker = operation.scriptText.length; + const isNew = lastShownRef.current == null || marker !== lastShownRef.current; + if (isNew) { + lastShownRef.current = marker; + if (navigationState.selectedTool === 'showJS' && navigationState.workbench !== WORKBENCH_ID) { + navigationActions.setWorkbench(WORKBENCH_ID); + } + } + } else { + clearCustomWorkbenchViewData(VIEW_ID); + lastShownRef.current = null; + } + }, [ + clearCustomWorkbenchViewData, + navigationActions, + navigationState.selectedTool, + navigationState.workbench, + operation.scriptText, + setCustomWorkbenchViewData, + ]); + + useEffect(() => { + if ((base.selectedFiles?.length ?? 0) === 0) { + try { base.operation.resetResults(); } catch { /* noop */ } + try { clearCustomWorkbenchViewData(VIEW_ID); } catch { /* noop */ } + if (navigationState.workbench === WORKBENCH_ID) { + try { navigationActions.setWorkbench('fileEditor'); } catch { /* noop */ } + } + lastShownRef.current = null; + } + }, [ + base.selectedFiles?.length, + base.operation, + clearCustomWorkbenchViewData, + navigationActions, + navigationState.workbench, + ]); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: false, + }, + steps: [], + executeButton: { + text: hasResults ? t('back', 'Back') : t('showJS.submit', 'Extract JavaScript'), + loadingText: t('loading', 'Loading...'), + onClick: hasResults + ? async () => { + // Clear results and deselect files so user can pick another file + try { + await base.operation.resetResults(); + } catch { /* noop */ } + try { + clearSelections(); + } catch { /* noop */ } + // Close the custom JS view and send user back to file manager to pick another file + try { + clearCustomWorkbenchViewData(VIEW_ID); + } catch { /* noop */ } + try { + navigationActions.setWorkbench('fileEditor'); + } catch { /* noop */ } + } + : base.handleExecute, + disabled: hasResults + ? false + : ( + !base.hasFiles || + (base.selectedFiles?.length ?? 0) !== 1 || + base.operation.isLoading || + base.endpointLoading || + base.endpointEnabled === false + ), + isVisible: true, + }, + review: { + isVisible: hasResults, + operation: base.operation, + title: t('showJS.results', 'Result'), + onUndo: undefined, + }, + }); +}; + +const ShowJSTool = ShowJS as ToolComponent; +ShowJSTool.tool = () => useShowJSOperation; +ShowJSTool.getDefaultParameters = () => ({ ...defaultParameters }); + +export default ShowJSTool; + + From 74a1438c212e92892377cbdb4f8f4570e91227df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:18:15 +0100 Subject: [PATCH 04/16] [V2] feat(unzip/front-end): Implement ZIP extraction confirmation for archives over 20 files (#4834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: - Introduced a user confirmation dialog for extracting ZIP files with more than **20 files**. - Created `useZipConfirmation` hook to handle confirmation dialog logic and state. - Implemented `ZipWarningModal` component to display the confirmation dialog. - Updated `zipFileService` to count files in ZIP and trigger confirmation callback for large files. - Integrated confirmation flow into `FileContext` and `useToolResources`. - Added translations for new ZIP warning dialog messages. This pull request introduces a user confirmation dialog when attempting to extract large ZIP files (**over 20 files**), improving safety and user experience by preventing accidental extraction of very large archives. The implementation includes a reusable confirmation modal, a custom hook to handle dialog state and resolution, and updates to the ZIP extraction logic to support this workflow. **User Experience Improvements** * Added a new localized warning dialog (`ZipWarningModal`) that prompts users for confirmation when extracting ZIP files containing more than 20 files. This dialog displays the ZIP file name, file count, and offers "Cancel" and "Extract" actions, with responsive layouts for desktop and mobile **ZIP Extraction Workflow Enhancements** * Updated the ZIP extraction logic in `ZipFileService` to count the number of files in a ZIP and invoke a confirmation callback if the file count exceeds the threshold. Extraction proceeds only if the user confirms; otherwise, the ZIP remains unextracted. * Added a new hook (`useZipConfirmation`) to manage the confirmation dialog’s state and provide a promise-based API for requesting user confirmation. **Integration with Application State** * Integrated the confirmation workflow into `FileContext`, passing the confirmation function into ZIP extraction calls and rendering the modal dialog at the appropriate time. * Updated relevant interfaces and method signatures to support the optional confirmation callback for large ZIP extractions throughout the codebase. image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] 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. --------- Signed-off-by: Balázs Szücs Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/en-GB/translation.json | 6 ++ .../components/shared/ZipWarningModal.tsx | 96 +++++++++++++++++++ frontend/src/core/contexts/FileContext.tsx | 18 +++- .../src/core/contexts/file/fileActions.ts | 5 +- .../hooks/tools/shared/useToolResources.ts | 9 +- frontend/src/core/hooks/useZipConfirmation.ts | 75 +++++++++++++++ frontend/src/core/services/zipFileService.ts | 44 ++++++--- .../tests/convert/ConvertIntegration.test.tsx | 17 ++-- .../ConvertSmartDetectionIntegration.test.tsx | 17 ++-- 9 files changed, 255 insertions(+), 32 deletions(-) create mode 100644 frontend/src/core/components/shared/ZipWarningModal.tsx create mode 100644 frontend/src/core/hooks/useZipConfirmation.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 4e9904f190..4b33f60341 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -35,6 +35,12 @@ "discardChanges": "Discard & Leave", "applyAndContinue": "Save & Leave", "exportAndContinue": "Export & Continue", + "zipWarning": { + "title": "Large ZIP File", + "message": "This ZIP contains {{count}} files. Extract anyway?", + "cancel": "Cancel", + "confirm": "Extract" + }, "language": { "direction": "ltr" }, diff --git a/frontend/src/core/components/shared/ZipWarningModal.tsx b/frontend/src/core/components/shared/ZipWarningModal.tsx new file mode 100644 index 0000000000..909cf1b31b --- /dev/null +++ b/frontend/src/core/components/shared/ZipWarningModal.tsx @@ -0,0 +1,96 @@ +import { Modal, Text, Button, Group, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import CancelIcon from "@mui/icons-material/Cancel"; +import { CSSProperties } from "react"; + +interface ZipWarningModalProps { + opened: boolean; + onConfirm: () => void; + onCancel: () => void; + fileCount: number; + zipFileName: string; +} + +const WARNING_ICON_STYLE: CSSProperties = { + fontSize: 36, + display: 'block', + margin: '0 auto 8px', + color: 'var(--mantine-color-blue-6)' +}; + +const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => { + const { t } = useTranslation(); + + return ( + + + + + {zipFileName} + + + {t("zipWarning.message", { + count: fileCount, + defaultValue: "This ZIP contains {{count}} files. Extract anyway?" + })} + + + + {/* Desktop layout: centered buttons */} + + + + + + {/* Mobile layout: vertical stack */} + + + + + + ); +}; + +export default ZipWarningModal; diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index 60d95b6767..56f1dcabde 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -31,6 +31,8 @@ import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createF import { FileLifecycleManager } from '@app/contexts/file/lifecycle'; import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts'; import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext'; +import { useZipConfirmation } from '@app/hooks/useZipConfirmation'; +import ZipWarningModal from '@app/components/shared/ZipWarningModal'; const DEBUG = process.env.NODE_ENV === 'development'; @@ -52,6 +54,9 @@ function FileContextInner({ const stateRef = useRef(state); stateRef.current = state; + // ZIP confirmation dialog + const { confirmationState, requestConfirmation, handleConfirm, handleCancel } = useZipConfirmation(); + // Create lifecycle manager const lifecycleManagerRef = useRef(null); if (!lifecycleManagerRef.current) { @@ -86,7 +91,9 @@ function FileContextInner({ ...options, // For direct file uploads: ALWAYS unzip (except HTML ZIPs) // skipAutoUnzip bypasses preference checks - HTML detection still applies - skipAutoUnzip: true + skipAutoUnzip: true, + // Provide confirmation callback for large ZIP files + confirmLargeExtraction: requestConfirmation }, stateRef, filesRef, @@ -101,7 +108,7 @@ function FileContextInner({ } return stirlingFiles; - }, [enablePersistence]); + }, [enablePersistence, requestConfirmation]); const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { // StirlingFileStubs preserve all metadata - perfect for FileManager use case! @@ -237,6 +244,13 @@ function FileContextInner({ {children} + ); diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index 0a37134f12..d209781c89 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -186,6 +186,7 @@ interface AddFileOptions { autoUnzip?: boolean; autoUnzipFileLimit?: number; skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs. + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; // Optional callback to confirm extraction of large ZIP files } /** @@ -219,6 +220,7 @@ export async function addFiles( const autoUnzip = options.autoUnzip ?? true; // Default to true const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit const skipAutoUnzip = options.skipAutoUnzip ?? false; + const confirmLargeExtraction = options.confirmLargeExtraction; for (const file of files) { // Check if file is a ZIP @@ -238,7 +240,8 @@ export async function addFiles( const extractedFiles = await zipFileService.extractWithPreferences(file, { autoUnzip, autoUnzipFileLimit, - skipAutoUnzip + skipAutoUnzip, + confirmLargeExtraction }); if (extractedFiles.length === 1 && extractedFiles[0] === file) { diff --git a/frontend/src/core/hooks/tools/shared/useToolResources.ts b/frontend/src/core/hooks/tools/shared/useToolResources.ts index 086a00f49c..a80c2c298b 100644 --- a/frontend/src/core/hooks/tools/shared/useToolResources.ts +++ b/frontend/src/core/hooks/tools/shared/useToolResources.ts @@ -83,12 +83,17 @@ export const useToolResources = () => { return results; }, []); - const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { + const extractZipFiles = useCallback(async ( + zipBlob: Blob, + skipAutoUnzip = false, + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise + ): Promise => { try { return await zipFileService.extractWithPreferences(zipBlob, { autoUnzip: preferences.autoUnzip, autoUnzipFileLimit: preferences.autoUnzipFileLimit, - skipAutoUnzip + skipAutoUnzip, + confirmLargeExtraction }); } catch (error) { console.error('useToolResources.extractZipFiles - Error:', error); diff --git a/frontend/src/core/hooks/useZipConfirmation.ts b/frontend/src/core/hooks/useZipConfirmation.ts new file mode 100644 index 0000000000..e23a2ce80d --- /dev/null +++ b/frontend/src/core/hooks/useZipConfirmation.ts @@ -0,0 +1,75 @@ +import { useState, useCallback, useRef } from 'react'; + +interface ZipConfirmationState { + opened: boolean; + fileCount: number; + fileName: string; +} + +/** + * Hook to manage ZIP warning confirmation dialog + * Returns state and handlers for the confirmation dialog + * Uses useRef to avoid recreating callbacks on every state change + */ +export const useZipConfirmation = () => { + const [confirmationState, setConfirmationState] = useState({ + opened: false, + fileCount: 0, + fileName: '', + }); + + // Store resolve function in ref to avoid callback recreation + const resolveRef = useRef<((value: boolean) => void) | null>(null); + + /** + * Request confirmation from user for extracting a large ZIP file + * Returns a Promise that resolves to true if user confirms, false if cancelled + */ + const requestConfirmation = useCallback((fileCount: number, fileName: string): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setConfirmationState({ + opened: true, + fileCount, + fileName, + }); + }); + }, []); + + /** + * Handle user confirmation - extract the ZIP + */ + const handleConfirm = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(true); + resolveRef.current = null; + } + setConfirmationState({ + opened: false, + fileCount: 0, + fileName: '', + }); + }, []); // No dependencies - uses ref + + /** + * Handle user cancellation - keep ZIP as-is + */ + const handleCancel = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(false); + resolveRef.current = null; + } + setConfirmationState({ + opened: false, + fileCount: 0, + fileName: '', + }); + }, []); // No dependencies - uses ref + + return { + confirmationState, + requestConfirmation, + handleConfirm, + handleCancel, + }; +}; diff --git a/frontend/src/core/services/zipFileService.ts b/frontend/src/core/services/zipFileService.ts index f7f157283f..f4ed398652 100644 --- a/frontend/src/core/services/zipFileService.ts +++ b/frontend/src/core/services/zipFileService.ts @@ -44,6 +44,9 @@ export class ZipFileService { private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit + // Warn user when extracting ZIP with more than this many files + public static readonly ZIP_WARNING_THRESHOLD = 20; + // ZIP file validation constants private static readonly VALID_ZIP_TYPES = [ 'application/zip', @@ -361,31 +364,35 @@ export class ZipFileService { /** * Determine if a ZIP file should be extracted based on user preferences + * Returns both the extraction decision and file count to avoid redundant ZIP parsing * * @param zipBlob - The ZIP file to check * @param autoUnzip - User preference for auto-unzipping * @param autoUnzipFileLimit - Maximum number of files to auto-extract * @param skipAutoUnzip - Bypass preference check (for automation) - * @returns true if the ZIP should be extracted, false otherwise + * @returns Object with shouldExtract flag and fileCount */ async shouldUnzip( zipBlob: Blob | File, autoUnzip: boolean, autoUnzipFileLimit: number, skipAutoUnzip: boolean = false - ): Promise { + ): Promise<{ shouldExtract: boolean; fileCount: number }> { try { - // Automation always extracts + // Automation always extracts - but still need to count files for warning if (skipAutoUnzip) { - return true; + const zip = new JSZip(); + const zipContents = await zip.loadAsync(zipBlob); + const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length; + return { shouldExtract: true, fileCount }; } // Check if auto-unzip is enabled if (!autoUnzip) { - return false; + return { shouldExtract: false, fileCount: 0 }; } - // Load ZIP and count files + // Load ZIP and count files (single parse) const zip = new JSZip(); const zipContents = await zip.loadAsync(zipBlob); @@ -393,20 +400,22 @@ export class ZipFileService { const fileCount = Object.values(zipContents.files).filter(entry => !entry.dir).length; // Only extract if within limit - return fileCount <= autoUnzipFileLimit; + return { + shouldExtract: fileCount <= autoUnzipFileLimit, + fileCount + }; } catch (error) { console.error('Error checking shouldUnzip:', error); // On error, default to not extracting (safer) - return false; + return { shouldExtract: false, fileCount: 0 }; } } - /** * Extract files from ZIP with HTML detection and preference checking - * This is the unified method that handles the common pattern of: * 1. Check for HTML files → keep zipped if present * 2. Check user preferences → respect autoUnzipFileLimit - * 3. Extract files if appropriate + * 3. Show warning for large ZIPs (>20 files) if callback provided + * 4. Extract files if appropriate * * @param zipBlob - The ZIP blob to process * @param options - Extraction options @@ -418,6 +427,7 @@ export class ZipFileService { autoUnzip: boolean; autoUnzipFileLimit: number; skipAutoUnzip?: boolean; + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; } ): Promise { try { @@ -432,8 +442,8 @@ export class ZipFileService { return [zipFile]; } - // Check if we should extract based on preferences - const shouldExtract = await this.shouldUnzip( + // Check if we should extract based on preferences (returns both decision and count) + const { shouldExtract, fileCount } = await this.shouldUnzip( zipBlob, options.autoUnzip, options.autoUnzipFileLimit, @@ -444,6 +454,14 @@ export class ZipFileService { return [zipFile]; } + // Warn user if ZIP is large (fileCount already obtained from shouldUnzip) + if (fileCount > ZipFileService.ZIP_WARNING_THRESHOLD && options.confirmLargeExtraction) { + const userConfirmed = await options.confirmLargeExtraction(fileCount, zipFile.name); + if (!userConfirmed) { + return [zipFile]; // User cancelled, keep ZIP as-is + } + } + // Extract all files const extractionResult = await this.extractAllFiles(zipFile); return extractionResult.success ? extractionResult.extractedFiles : [zipFile]; diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index 5d0d899d7e..80e8760c7a 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -20,6 +20,7 @@ import { I18nextProvider } from 'react-i18next'; import i18n from '@app/i18n/config'; import { createTestStirlingFile } from '@app/tests/utils/testFileHelpers'; import { StirlingFile } from '@app/types/fileContext'; +import { MantineProvider } from '@mantine/core'; // Mock axios (for static methods like CancelToken, isCancel) vi.mock('axios', () => ({ @@ -88,13 +89,15 @@ const createPDFFile = (): StirlingFile => { // Test wrapper component const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - - {children} - - - + + + + + {children} + + + + ); describe('Convert Tool Integration Tests', () => { diff --git a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx index a47bb26fc5..9994d36a73 100644 --- a/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertSmartDetectionIntegration.test.tsx @@ -15,6 +15,7 @@ import i18n from '@app/i18n/config'; import { detectFileExtension } from '@app/utils/fileUtils'; import { FIT_OPTIONS } from '@app/constants/convertConstants'; import { createTestStirlingFile, createTestFilesWithId } from '@app/tests/utils/testFileHelpers'; +import { MantineProvider } from '@mantine/core'; // Mock axios (for static methods like CancelToken, isCancel) vi.mock('axios', () => ({ @@ -76,13 +77,15 @@ vi.mock('../../services/thumbnailGenerationService', () => ({ })); const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - - - - {children} - - - + + + + + {children} + + + + ); describe('Convert Tool - Smart Detection Integration Tests', () => { From a05c5a53c7a2e16cd6192d389a4c70da221c1f5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:27:41 +0100 Subject: [PATCH 05/16] [V2] feat(convert): add support for CBZ to PDF and PDF to CBZ conversion (#4831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: - Implemented `ConvertFromCbzSettings` component for CBZ to PDF options - Added `ConvertToCbzSettings` component for PDF to CBZ options - Extended conversion constants with new CBZ-related formats and mappings - Updated `ConvertSettings` to include CBZ-specific configuration forms - Updated `useConvertOperation` to handle CBZ conversion parameters For backend reference see this PR: #4472 This pull request adds support for converting between CBZ (Comic Book Zip) and PDF formats in the frontend conversion tool. It introduces new UI components for CBZ-to-PDF and PDF-to-CBZ conversion options, updates the conversion parameters and form data logic, and integrates these formats into the available conversion options and endpoints. **CBZ/PDF Conversion Support:** * Added `ConvertFromCbzSettings` and `ConvertToCbzSettings` components to provide user options for CBZ-to-PDF and PDF-to-CBZ conversions, including "Optimize for ebook" and DPI settings. **Conversion Logic and Parameters:** * Extended the `ConvertParameters` interface and default parameters to include `cbzOptions` (for CBZ-to-PDF) and `cbzOutputOptions` (for PDF-to-CBZ). **Conversion Matrix and Endpoint Integration:** * Added CBZ to the supported "from" and "to" format lists, updated the conversion matrix to allow CBZ-to-PDF and PDF-to-CBZ, and mapped the new conversions to appropriate API endpoints. * Updated URL mapping to recognize `/cbz-to-pdf` and `/pdf-to-cbz` as conversion tool routes. ### Front-end image image image image image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] 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. --------- Signed-off-by: Balázs Szücs --- .../public/locales/en-GB/translation.json | 6 +- .../fileEditor/FileEditorThumbnail.tsx | 9 ++- .../tools/convert/ConvertFromCbzSettings.tsx | 36 ++++++++++ .../tools/convert/ConvertSettings.tsx | 38 ++++++++++ .../tools/convert/ConvertToCbzSettings.tsx | 39 ++++++++++ .../src/core/constants/convertConstants.ts | 13 +++- .../core/constants/convertSupportedFornats.ts | 2 +- .../tools/convert/useConvertOperation.ts | 6 +- .../tools/convert/useConvertParameters.ts | 12 ++++ .../tests/convert/ConvertIntegration.test.tsx | 72 +++++++++++++++++++ frontend/src/core/utils/urlMapping.ts | 2 + 11 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx create mode 100644 frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 4b33f60341..aae5bbc9ed 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1105,7 +1105,11 @@ "markdown": "Markdown", "textRtf": "Text/RTF", "grayscale": "Greyscale", - "errorConversion": "An error occurred while converting the file." + "errorConversion": "An error occurred while converting the file.", + "cbzOptions": "CBZ to PDF Options", + "optimizeForEbook": "Optimize PDF for ebook readers (uses Ghostscript)", + "cbzOutputOptions": "PDF to CBZ Options", + "cbzDpi": "DPI for image rendering" }, "imageToPdf": { "tags": "conversion,img,jpg,picture,photo" diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index b51152af1a..e268b941ce 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -93,6 +93,13 @@ const FileEditorThumbnail = ({ return (m?.[1] || '').toUpperCase(); }, [file.name]); + const extLower = useMemo(() => { + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); + return (m?.[1] || '').toLowerCase(); + }, [file.name]); + + const isCBZ = extLower === 'cbz'; + const pageLabel = useMemo( () => pageCount > 0 @@ -206,7 +213,7 @@ const FileEditorThumbnail = ({ alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); } }, - hidden: !isZipFile || !onUnzipFile, + hidden: !isZipFile || !onUnzipFile || isCBZ, }, { id: 'close', diff --git a/frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx b/frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx new file mode 100644 index 0000000000..babb202559 --- /dev/null +++ b/frontend/src/core/components/tools/convert/ConvertFromCbzSettings.tsx @@ -0,0 +1,36 @@ +import { Stack, Text, Checkbox } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters'; + +interface ConvertFromCbzSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; + disabled?: boolean; +} + +const ConvertFromCbzSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertFromCbzSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t('convert.cbzOptions', 'CBZ to PDF Options')}: + + onParameterChange('cbzOptions', { + ...parameters.cbzOptions, + optimizeForEbook: event.currentTarget.checked + })} + disabled={disabled} + data-testid="optimize-ebook-checkbox" + /> + + ); +}; + +export default ConvertFromCbzSettings; diff --git a/frontend/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/src/core/components/tools/convert/ConvertSettings.tsx index d332f888e3..d9727b0789 100644 --- a/frontend/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/src/core/components/tools/convert/ConvertSettings.tsx @@ -13,6 +13,8 @@ import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImage import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings"; import ConvertFromWebSettings from "@app/components/tools/convert/ConvertFromWebSettings"; import ConvertFromEmailSettings from "@app/components/tools/convert/ConvertFromEmailSettings"; +import ConvertFromCbzSettings from "@app/components/tools/convert/ConvertFromCbzSettings"; +import ConvertToCbzSettings from "@app/components/tools/convert/ConvertToCbzSettings"; import ConvertToPdfaSettings from "@app/components/tools/convert/ConvertToPdfaSettings"; import { ConvertParameters } from "@app/hooks/tools/convert/useConvertParameters"; import { @@ -118,6 +120,12 @@ const ConvertSettings = ({ onParameterChange('pdfaOptions', { outputFormat: 'pdfa-1', }); + onParameterChange('cbzOptions', { + optimizeForEbook: false, + }); + onParameterChange('cbzOutputOptions', { + dpi: 150, + }); onParameterChange('isSmartDetection', false); onParameterChange('smartDetectionType', 'none'); }; @@ -180,6 +188,12 @@ const ConvertSettings = ({ onParameterChange('pdfaOptions', { outputFormat: 'pdfa-1', }); + onParameterChange('cbzOptions', { + optimizeForEbook: false, + }); + onParameterChange('cbzOutputOptions', { + dpi: 150, + }); }; @@ -293,6 +307,30 @@ const ConvertSettings = ({ )} + {/* CBZ to PDF options */} + {parameters.fromExtension === 'cbz' && parameters.toExtension === 'pdf' && ( + <> + + + + )} + + {/* PDF to CBZ options */} + {parameters.fromExtension === 'pdf' && parameters.toExtension === 'cbz' && ( + <> + + + + )} + {/* PDF to PDF/A options */} {parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa' && ( <> diff --git a/frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx b/frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx new file mode 100644 index 0000000000..774219ea57 --- /dev/null +++ b/frontend/src/core/components/tools/convert/ConvertToCbzSettings.tsx @@ -0,0 +1,39 @@ +import { Stack, Text, NumberInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { ConvertParameters } from '@app/hooks/tools/convert/useConvertParameters'; + +interface ConvertToCbzSettingsProps { + parameters: ConvertParameters; + onParameterChange: (key: K, value: ConvertParameters[K]) => void; + disabled?: boolean; +} + +const ConvertToCbzSettings = ({ + parameters, + onParameterChange, + disabled = false +}: ConvertToCbzSettingsProps) => { + const { t } = useTranslation(); + + return ( + + {t('convert.cbzOutputOptions', 'PDF to CBZ Options')}: + + typeof val === 'number' && onParameterChange('cbzOutputOptions', { + ...parameters.cbzOutputOptions, + dpi: val + })} + min={72} + max={600} + step={1} + disabled={disabled} + /> + + ); +}; + +export default ConvertToCbzSettings; diff --git a/frontend/src/core/constants/convertConstants.ts b/frontend/src/core/constants/convertConstants.ts index 5978d523b1..0dc6e37263 100644 --- a/frontend/src/core/constants/convertConstants.ts +++ b/frontend/src/core/constants/convertConstants.ts @@ -21,6 +21,8 @@ export const CONVERSION_ENDPOINTS = { 'office-pdf': '/api/v1/convert/file/pdf', 'pdf-image': '/api/v1/convert/pdf/img', 'image-pdf': '/api/v1/convert/img/pdf', + 'cbz-pdf': '/api/v1/convert/cbz/pdf', + 'pdf-cbz': '/api/v1/convert/pdf/cbz', 'pdf-office-word': '/api/v1/convert/pdf/word', 'pdf-office-presentation': '/api/v1/convert/pdf/presentation', 'pdf-office-text': '/api/v1/convert/pdf/text', @@ -38,6 +40,8 @@ export const ENDPOINT_NAMES = { 'office-pdf': 'file-to-pdf', 'pdf-image': 'pdf-to-img', 'image-pdf': 'img-to-pdf', + 'cbz-pdf': 'cbz-to-pdf', + 'pdf-cbz': 'pdf-to-cbz', 'pdf-office-word': 'pdf-to-word', 'pdf-office-presentation': 'pdf-to-presentation', 'pdf-office-text': 'pdf-to-text', @@ -57,6 +61,7 @@ export const FROM_FORMAT_OPTIONS = [ { value: 'any', label: 'Any', group: 'Multiple Files' }, { value: 'image', label: 'Images', group: 'Multiple Files' }, { value: 'pdf', label: 'PDF', group: 'Document' }, + { value: 'cbz', label: 'CBZ', group: 'Archive' }, { value: 'docx', label: 'DOCX', group: 'Document' }, { value: 'doc', label: 'DOC', group: 'Document' }, { value: 'odt', label: 'ODT', group: 'Document' }, @@ -87,6 +92,7 @@ export const TO_FORMAT_OPTIONS = [ { value: 'pdfa', label: 'PDF/A', group: 'Document' }, { value: 'docx', label: 'DOCX', group: 'Document' }, { value: 'odt', label: 'ODT', group: 'Document' }, + { value: 'cbz', label: 'CBZ', group: 'Archive' }, { value: 'csv', label: 'CSV', group: 'Spreadsheet' }, { value: 'pptx', label: 'PPTX', group: 'Presentation' }, { value: 'odp', label: 'ODP', group: 'Presentation' }, @@ -107,7 +113,8 @@ export const TO_FORMAT_OPTIONS = [ export const CONVERSION_MATRIX: Record = { 'any': ['pdf'], // Mixed files always convert to PDF 'image': ['pdf'], // Multiple images always convert to PDF - 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa'], + 'pdf': ['png', 'jpg', 'gif', 'tiff', 'bmp', 'webp', 'docx', 'odt', 'pptx', 'odp', 'csv', 'txt', 'rtf', 'md', 'html', 'xml', 'pdfa', 'cbz'], + 'cbz': ['pdf'], 'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'], 'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'], 'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'], @@ -130,8 +137,10 @@ export const EXTENSION_TO_ENDPOINT: Record> = { 'csv': 'pdf-to-csv', 'txt': 'pdf-to-text', 'rtf': 'pdf-to-text', 'md': 'pdf-to-markdown', 'html': 'pdf-to-html', 'xml': 'pdf-to-xml', - 'pdfa': 'pdf-to-pdfa' + 'pdfa': 'pdf-to-pdfa', + 'cbz': 'pdf-to-cbz' }, + 'cbz': { 'pdf': 'cbz-to-pdf' }, 'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { 'pdf': 'file-to-pdf' }, 'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' }, 'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' }, diff --git a/frontend/src/core/constants/convertSupportedFornats.ts b/frontend/src/core/constants/convertSupportedFornats.ts index b9bea32279..86138c4e68 100644 --- a/frontend/src/core/constants/convertSupportedFornats.ts +++ b/frontend/src/core/constants/convertSupportedFornats.ts @@ -13,7 +13,7 @@ export const CONVERT_SUPPORTED_FORMATS = [ // Email formats 'eml', // Archive formats - 'zip', + 'zip', 'cbz', // Other 'dbf', 'fods', 'vsd', 'vor', 'vor3', 'vor4', 'uop', 'pct', 'ps', 'pdf', ]; diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts index 28080407d6..9134c9db4a 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts @@ -39,7 +39,7 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile formData.append("fileInput", file); }); - const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions } = parameters; + const { fromExtension, toExtension, imageOptions, htmlOptions, emailOptions, pdfaOptions, cbzOptions, cbzOutputOptions } = parameters; if (isImageFormat(toExtension)) { formData.append("imageFormat", toExtension); @@ -67,6 +67,10 @@ export const buildConvertFormData = (parameters: ConvertParameters, selectedFile formData.append("outputFormat", pdfaOptions.outputFormat); } else if (fromExtension === 'pdf' && toExtension === 'csv') { formData.append("pageNumbers", "all"); + } else if (fromExtension === 'cbz' && toExtension === 'pdf') { + formData.append("optimizeForEbook", (cbzOptions?.optimizeForEbook ?? false).toString()); + } else if (fromExtension === 'pdf' && toExtension === 'cbz') { + formData.append("dpi", (cbzOutputOptions?.dpi ?? 150).toString()); } return formData; diff --git a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts index c9c19176e3..eaa05d5718 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertParameters.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertParameters.ts @@ -36,6 +36,12 @@ export interface ConvertParameters extends BaseParameters { pdfaOptions: { outputFormat: string; }; + cbzOptions: { + optimizeForEbook: boolean; + }; + cbzOutputOptions: { + dpi: number; + }; isSmartDetection: boolean; smartDetectionType: 'mixed' | 'images' | 'web' | 'none'; } @@ -69,6 +75,12 @@ export const defaultParameters: ConvertParameters = { pdfaOptions: { outputFormat: 'pdfa-1', }, + cbzOptions: { + optimizeForEbook: false, + }, + cbzOutputOptions: { + dpi: 150, + }, isSmartDetection: false, smartDetectionType: 'none', }; diff --git a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx index 80e8760c7a..bb2f0bcf11 100644 --- a/frontend/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/core/tests/convert/ConvertIntegration.test.tsx @@ -151,6 +151,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -218,6 +224,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -263,6 +275,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -317,6 +335,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -375,6 +399,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -431,6 +461,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -483,6 +519,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -532,6 +574,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -583,6 +631,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -631,6 +685,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -685,6 +745,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; @@ -738,6 +804,12 @@ describe('Convert Tool Integration Tests', () => { }, pdfaOptions: { outputFormat: '' + }, + cbzOptions: { + optimizeForEbook: false + }, + cbzOutputOptions: { + dpi: 150 } }; diff --git a/frontend/src/core/utils/urlMapping.ts b/frontend/src/core/utils/urlMapping.ts index c76adf856c..ba6fe1aaa7 100644 --- a/frontend/src/core/utils/urlMapping.ts +++ b/frontend/src/core/utils/urlMapping.ts @@ -28,6 +28,8 @@ export const URL_TO_TOOL_MAP: Record = { '/pdf-to-pdfa': 'convert', '/pdf-to-word': 'convert', '/pdf-to-xml': 'convert', + '/cbz-to-pdf': 'convert', + '/pdf-to-cbz': 'convert', // Security tools '/add-password': 'addPassword', From eb5f36aa156738e31fc495b20b90d3e7100ef83d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 12 Nov 2025 15:47:37 +0000 Subject: [PATCH 06/16] Fix issues with opening files in desktop app (#4876) # Description of Changes Locking to just having one instance of the app running unifies the experience across all OSs. Opening new files in Stirling will cause the files to be opened in the existing window rather than spawning a new instance of the app with just that file in the new instance. There's much more to explore here to allow multiple windows open at once, but that can be done all from one instance of the app, and will likely make it easier to allow movement of files etc. across different windows. Also fixes extra newlines in the logs and directly builds to `.app` on Mac because it's frustrating during development to have to repeatedly mount & unmount the `.dmg`. --- frontend/src-tauri/Cargo.lock | 420 ++++++++++++++++++ frontend/src-tauri/Cargo.toml | 1 + frontend/src-tauri/src/commands/backend.rs | 4 + frontend/src-tauri/src/commands/files.rs | 66 ++- frontend/src-tauri/src/commands/mod.rs | 4 +- frontend/src-tauri/src/lib.rs | 40 +- frontend/src-tauri/tauri.conf.json | 2 +- .../src/desktop/hooks/useAppInitialization.ts | 85 +++- frontend/src/desktop/hooks/useOpenedFile.ts | 30 +- .../src/desktop/services/fileOpenService.ts | 50 +-- 10 files changed, 591 insertions(+), 111 deletions(-) diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 84cd44f029..d2abe9651d 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -81,6 +81,137 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "atk" version = "0.18.2" @@ -182,6 +313,19 @@ dependencies = [ "objc2 0.6.3", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.5.7" @@ -424,6 +568,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -774,6 +927,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -811,6 +991,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -966,6 +1167,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1352,6 +1566,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2079,6 +2299,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2463,6 +2696,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2498,6 +2741,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2679,6 +2928,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2711,6 +2971,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.3" @@ -3657,6 +3931,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stirling-pdf" version = "0.1.0" @@ -3670,6 +3950,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tokio", ] @@ -4056,6 +4337,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.17", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.9.1" @@ -4463,9 +4759,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "tracing-core" version = "0.1.34" @@ -4515,6 +4823,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5530,6 +5849,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.13", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.7.13", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -5603,3 +5983,43 @@ dependencies = [ "quote", "syn 2.0.108", ] + +[[package]] +name = "zvariant" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.13", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.108", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.108", + "winnow 0.7.13", +] diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 0ba9b7886f..e14bbaaeeb 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -28,5 +28,6 @@ tauri = { version = "2.9.0", features = [ "devtools"] } tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" 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"] } diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index c465f7cc84..c7bce50f7d 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -234,6 +234,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { let output_str = String::from_utf8_lossy(&output); + // Strip exactly one trailing newline to avoid double newlines + let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str); add_log(format!("📤 Backend: {}", output_str)); // Look for startup indicators @@ -250,6 +252,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { let output_str = String::from_utf8_lossy(&output); + // Strip exactly one trailing newline to avoid double newlines + let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str); add_log(format!("📥 Backend Error: {}", output_str)); // Look for error indicators diff --git a/frontend/src-tauri/src/commands/files.rs b/frontend/src-tauri/src/commands/files.rs index 4b1cd2ad7c..2d22ac53ea 100644 --- a/frontend/src-tauri/src/commands/files.rs +++ b/frontend/src-tauri/src/commands/files.rs @@ -1,49 +1,47 @@ use crate::utils::add_log; use std::sync::Mutex; -// Store the opened file path globally -static OPENED_FILE: Mutex> = Mutex::new(None); +// Store the opened file paths globally (supports multiple files) +static OPENED_FILES: Mutex> = Mutex::new(Vec::new()); -// Set the opened file path (called by macOS file open events) -#[cfg(target_os = "macos")] -pub fn set_opened_file(file_path: String) { - let mut opened_file = OPENED_FILE.lock().unwrap(); - *opened_file = Some(file_path.clone()); - add_log(format!("📂 File opened via file open event: {}", file_path)); +// Add an opened file path +pub fn add_opened_file(file_path: String) { + let mut opened_files = OPENED_FILES.lock().unwrap(); + opened_files.push(file_path.clone()); + add_log(format!("📂 File stored for later retrieval: {}", file_path)); } -// Command to get opened file path (if app was launched with a file) +// Command to get opened file paths (if app was launched with files) #[tauri::command] -pub async fn get_opened_file() -> Result, String> { - // First check if we have a file from macOS file open events - { - let opened_file = OPENED_FILE.lock().unwrap(); - if let Some(ref file_path) = *opened_file { - add_log(format!("📂 Returning stored opened file: {}", file_path)); - return Ok(Some(file_path.clone())); - } - } - - // Fallback to command line arguments (Windows/Linux) +pub async fn get_opened_files() -> Result, String> { + let mut all_files: Vec = Vec::new(); + + // Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour) let args: Vec = std::env::args().collect(); - - // Look for a PDF file argument (skip the first arg which is the executable) - for arg in args.iter().skip(1) { - if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() { - add_log(format!("📂 PDF file opened via command line: {}", arg)); - return Ok(Some(arg.clone())); - } + let pdf_files: Vec = 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()); } - - Ok(None) + + add_log(format!("📂 Returning {} opened file(s)", all_files.len())); + Ok(all_files) } -// Command to clear the opened file (after processing) +// Command to clear the opened files (after processing) #[tauri::command] -pub async fn clear_opened_file() -> Result<(), String> { - let mut opened_file = OPENED_FILE.lock().unwrap(); - *opened_file = None; - add_log("📂 Cleared opened file".to_string()); +pub async fn clear_opened_files() -> Result<(), String> { + let mut opened_files = OPENED_FILES.lock().unwrap(); + opened_files.clear(); + add_log("📂 Cleared opened files".to_string()); Ok(()) } diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index 3f4ed7ecdc..f21bf80426 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -4,6 +4,4 @@ pub mod files; pub use backend::{start_backend, cleanup_backend}; pub use health::check_backend_health; -pub use files::{get_opened_file, clear_opened_file}; -#[cfg(target_os = "macos")] -pub use files::set_opened_file; +pub use files::{get_opened_files, clear_opened_files, add_opened_file}; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 9045e1e6d1..9a07845e10 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,13 +1,9 @@ -use tauri::{RunEvent, WindowEvent}; -#[cfg(target_os = "macos")] -use tauri::Emitter; +use tauri::{RunEvent, WindowEvent, Emitter, Manager}; mod utils; mod commands; -use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend}; -#[cfg(target_os = "macos")] -use commands::set_opened_file; +use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file}; use utils::{add_log, get_tauri_logs}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -15,12 +11,35 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + // This callback runs when a second instance tries to start + add_log(format!("📂 Second instance detected with args: {:?}", args)); + + // Scan args for PDF files (skip first arg which is the executable) + for arg in args.iter().skip(1) { + if std::path::Path::new(arg).exists() { + add_log(format!("📂 Forwarding file to existing instance: {}", arg)); + + // 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(); + let _ = window.unminimize(); + } + } + } + })) .setup(|_app| { add_log("🚀 Tauri app setup started".to_string()); add_log("🔍 DEBUG: Setup completed".to_string()); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_file, clear_opened_file, get_tauri_logs]) + .invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { @@ -45,8 +64,9 @@ pub fn run() { let file_path = url_str.strip_prefix("file://").unwrap_or(url_str); if file_path.ends_with(".pdf") { add_log(format!("📂 Processing opened PDF: {}", file_path)); - set_opened_file(file_path.to_string()); - let _ = app_handle.emit("macos://open-file", file_path.to_string()); + 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()); } } } @@ -58,4 +78,4 @@ pub fn run() { } } }); -} \ No newline at end of file +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 45bb852dae..d845a2dc97 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "dmg", "msi"], + "targets": ["deb", "rpm", "dmg", "app", "msi"], "icon": [ "icons/icon.png", "icons/icon.icns", diff --git a/frontend/src/desktop/hooks/useAppInitialization.ts b/frontend/src/desktop/hooks/useAppInitialization.ts index c64ea11808..a34547bcd0 100644 --- a/frontend/src/desktop/hooks/useAppInitialization.ts +++ b/frontend/src/desktop/hooks/useAppInitialization.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useBackendInitializer } from '@app/hooks/useBackendInitializer'; import { useOpenedFile } from '@app/hooks/useOpenedFile'; import { fileOpenService } from '@app/services/fileOpenService'; @@ -17,31 +17,78 @@ export function useAppInitialization(): void { // Get file management actions const { addFiles } = useFileManagement(); - // Handle file opened with app (Tauri mode) - const { openedFilePath, loading: openedFileLoading } = useOpenedFile(); + // Handle files opened with app (Tauri mode) + const { openedFilePaths, loading: openedFileLoading } = useOpenedFile(); - // Load opened file and add directly to FileContext + // Track if we've already loaded the initial files to prevent duplicate loads + const initialFilesLoadedRef = useRef(false); + + // Load opened files and add directly to FileContext useEffect(() => { - if (openedFilePath && !openedFileLoading) { - const loadOpenedFile = async () => { - try { - const fileData = await fileOpenService.readFileAsArrayBuffer(openedFilePath); - if (fileData) { - // Create a File object from the ArrayBuffer - const file = new File([fileData.arrayBuffer], fileData.fileName, { - type: 'application/pdf' - }); + if (openedFilePaths.length > 0 && !openedFileLoading && !initialFilesLoadedRef.current) { + initialFilesLoadedRef.current = true; - // Add directly to FileContext - await addFiles([file]); - console.log('[Desktop] Opened file added to FileContext:', fileData.fileName); + const loadOpenedFiles = async () => { + try { + const filesArray: File[] = []; + + // Load all files in parallel + await Promise.all( + openedFilePaths.map(async (filePath) => { + try { + const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); + if (fileData) { + const file = new File([fileData.arrayBuffer], fileData.fileName, { + type: 'application/pdf' + }); + filesArray.push(file); + console.log('[Desktop] Loaded file:', fileData.fileName); + } + } catch (error) { + console.error('[Desktop] Failed to load file:', filePath, error); + } + }) + ); + + if (filesArray.length > 0) { + // Add all files to FileContext at once + await addFiles(filesArray); + console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`); } } catch (error) { - console.error('[Desktop] Failed to load opened file:', error); + console.error('[Desktop] Failed to load opened files:', error); } }; - loadOpenedFile(); + loadOpenedFiles(); } - }, [openedFilePath, openedFileLoading, addFiles]); + }, [openedFilePaths, openedFileLoading, addFiles]); + + // Listen for runtime file-opened events (from second instances on Windows/Linux) + useEffect(() => { + const handleRuntimeFileOpen = async (filePath: string) => { + try { + console.log('[Desktop] Runtime file-opened event received:', filePath); + const fileData = await fileOpenService.readFileAsArrayBuffer(filePath); + if (fileData) { + // Create a File object from the ArrayBuffer + const file = new File([fileData.arrayBuffer], fileData.fileName, { + type: 'application/pdf' + }); + + // Add directly to FileContext + await addFiles([file]); + console.log('[Desktop] Runtime opened file added to FileContext:', fileData.fileName); + } + } catch (error) { + console.error('[Desktop] Failed to load runtime opened file:', error); + } + }; + + // Set up event listener and get cleanup function + const unlisten = fileOpenService.onFileOpened(handleRuntimeFileOpen); + + // Clean up listener on unmount + return unlisten; + }, [addFiles]); } diff --git a/frontend/src/desktop/hooks/useOpenedFile.ts b/frontend/src/desktop/hooks/useOpenedFile.ts index 17d6a7870f..48565010fe 100644 --- a/frontend/src/desktop/hooks/useOpenedFile.ts +++ b/frontend/src/desktop/hooks/useOpenedFile.ts @@ -2,28 +2,28 @@ import { useState, useEffect } from 'react'; import { fileOpenService } from '@app/services/fileOpenService'; export function useOpenedFile() { - const [openedFilePath, setOpenedFilePath] = useState(null); + const [openedFilePaths, setOpenedFilePaths] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { const checkForOpenedFile = async () => { - console.log('🔍 Checking for opened file...'); + console.log('🔍 Checking for opened file(s)...'); try { - const filePath = await fileOpenService.getOpenedFile(); - console.log('🔍 fileOpenService.getOpenedFile() returned:', filePath); - - if (filePath) { - console.log('✅ App opened with file:', filePath); - setOpenedFilePath(filePath); - - // Clear the file from service state after consuming it - await fileOpenService.clearOpenedFile(); + const filePaths = await fileOpenService.getOpenedFiles(); + console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths); + + if (filePaths.length > 0) { + console.log(`✅ App opened with ${filePaths.length} file(s):`, filePaths); + setOpenedFilePaths(filePaths); + + // Clear the files from service state after consuming them + await fileOpenService.clearOpenedFiles(); } else { - console.log('ℹ️ No file was opened with the app'); + console.log('ℹ️ No files were opened with the app'); } } catch (error) { - console.error('❌ Failed to check for opened file:', error); + console.error('❌ Failed to check for opened files:', error); } finally { setLoading(false); } @@ -34,7 +34,7 @@ export function useOpenedFile() { // Listen for runtime file open events (abstracted through service) const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => { console.log('📂 Runtime file open event:', filePath); - setOpenedFilePath(filePath); + setOpenedFilePaths(prev => [...prev, filePath]); }); // Cleanup function @@ -43,5 +43,5 @@ export function useOpenedFile() { }; }, []); - return { openedFilePath, loading }; + return { openedFilePaths, loading }; } diff --git a/frontend/src/desktop/services/fileOpenService.ts b/frontend/src/desktop/services/fileOpenService.ts index 524776d326..67813df38d 100644 --- a/frontend/src/desktop/services/fileOpenService.ts +++ b/frontend/src/desktop/services/fileOpenService.ts @@ -1,22 +1,22 @@ import { invoke, isTauri } from '@tauri-apps/api/core'; export interface FileOpenService { - getOpenedFile(): Promise; + getOpenedFiles(): Promise; readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>; - clearOpenedFile(): Promise; + clearOpenedFiles(): Promise; onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function } class TauriFileOpenService implements FileOpenService { - async getOpenedFile(): Promise { + async getOpenedFiles(): Promise { try { - console.log('🔍 Calling invoke(get_opened_file)...'); - const result = await invoke('get_opened_file'); - console.log('🔍 invoke(get_opened_file) returned:', result); + console.log('🔍 Calling invoke(get_opened_files)...'); + const result = await invoke('get_opened_files'); + console.log('🔍 invoke(get_opened_files) returned:', result); return result; } catch (error) { - console.error('❌ Failed to get opened file:', error); - return null; + console.error('❌ Failed to get opened files:', error); + return []; } } @@ -37,13 +37,13 @@ class TauriFileOpenService implements FileOpenService { } } - async clearOpenedFile(): Promise { + async clearOpenedFiles(): Promise { try { - console.log('🔍 Calling invoke(clear_opened_file)...'); - await invoke('clear_opened_file'); - console.log('✅ Successfully cleared opened file'); + console.log('🔍 Calling invoke(clear_opened_files)...'); + await invoke('clear_opened_files'); + console.log('✅ Successfully cleared opened files'); } catch (error) { - console.error('❌ Failed to clear opened file:', error); + console.error('❌ Failed to clear opened files:', error); } } @@ -67,15 +67,9 @@ class TauriFileOpenService implements FileOpenService { return; } - // Listen for macOS native file open events - const unlistenMacOS = await listen('macos://open-file', (event) => { - console.log('📂 macOS native file open event:', event.payload); - callback(event.payload as string); - }); - - // Listen for fallback file open events - const unlistenFallback = await listen('file-opened', (event) => { - console.log('📂 Fallback file open event:', event.payload); + // Listen for unified file open events (all platforms) + const unlisten = await listen('file-opened', (event) => { + console.log('📂 File open event received:', event.payload); callback(event.payload as string); }); @@ -83,8 +77,7 @@ class TauriFileOpenService implements FileOpenService { if (!isCleanedUp) { cleanup = () => { try { - unlistenMacOS(); - unlistenFallback(); + unlisten(); console.log('✅ File event listeners cleaned up'); } catch (error) { console.error('❌ Error during file event cleanup:', error); @@ -93,8 +86,7 @@ class TauriFileOpenService implements FileOpenService { } else { // Clean up immediately if cleanup was called during setup try { - unlistenMacOS(); - unlistenFallback(); + unlisten(); } catch (error) { console.error('❌ Error during immediate cleanup:', error); } @@ -118,9 +110,9 @@ class TauriFileOpenService implements FileOpenService { } class WebFileOpenService implements FileOpenService { - async getOpenedFile(): Promise { + async getOpenedFiles(): Promise { // In web mode, there's no file association support - return null; + return []; } async readFileAsArrayBuffer(_filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> { @@ -128,7 +120,7 @@ class WebFileOpenService implements FileOpenService { return null; } - async clearOpenedFile(): Promise { + async clearOpenedFiles(): Promise { // In web mode, no file clearing needed } From 3ae2946d57a15872ebce550419fdba28882b104e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:59:11 +0100 Subject: [PATCH 07/16] [V2] refactor(ui): migrate compress/booklet/sign-up to Mantine Checkbox (#4886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Updated `BookletImpositionSettings` to use Mantine's `Checkbox` for better UI consistency - Replaced manual checkbox implementations with Mantine's `Checkbox` in `CompressSettings` - Added labels and tooltips for enhanced accessibility and usability ### Before: image image image image ### After: image image image image --- ## 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. --------- Signed-off-by: Balázs Szücs --- .../BookletImpositionSettings.tsx | 147 +++++++++--------- .../tools/compress/CompressSettings.tsx | 20 +-- .../proprietary/routes/signup/SignupForm.tsx | 20 +-- 3 files changed, 90 insertions(+), 97 deletions(-) diff --git a/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx index f0c76f04c2..d9c6682295 100644 --- a/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx +++ b/frontend/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core"; +import { Stack, Text, Divider, Collapse, Button, NumberInput, Checkbox } from "@mantine/core"; import { BookletImpositionParameters } from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters"; import ButtonSelector from "@app/components/shared/ButtonSelector"; @@ -21,28 +21,27 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f {/* Double Sided */} - + { + const isDoubleSided = event.currentTarget.checked; + onParameterChange('doubleSided', isDoubleSided); + // Reset to BOTH when turning double-sided back on + if (isDoubleSided) { + onParameterChange('duplexPass', 'BOTH'); + } else { + // Default to FIRST pass when going to manual duplex + onParameterChange('duplexPass', 'FIRST'); + } + }} + disabled={disabled} + label={ +
+ {t('bookletImposition.doubleSided.label', 'Double-sided printing')} + {t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')} +
+ } + /> {/* Manual Duplex Pass Selection - only show when double-sided is OFF */} {!parameters.doubleSided && ( @@ -90,47 +89,44 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f {/* Right-to-Left Binding */} - + onParameterChange('spineLocation', event.currentTarget.checked ? 'RIGHT' : 'LEFT')} + disabled={disabled} + label={ +
+ {t('bookletImposition.rtlBinding.label', 'Right-to-left binding')} + {t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')} +
+ } + /> {/* Add Border Option */} - + onParameterChange('addBorder', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('bookletImposition.addBorder.label', 'Add borders around pages')} + {t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')} +
+ } + /> {/* Gutter Margin */} - + onParameterChange('addGutter', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('bookletImposition.addGutter.label', 'Add gutter margin')} + {t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')} +
+ } + /> {parameters.addGutter && ( {/* Flip on Short Edge */} - + /> {/* Paper Size Note */} diff --git a/frontend/src/core/components/tools/compress/CompressSettings.tsx b/frontend/src/core/components/tools/compress/CompressSettings.tsx index 7a7c93d675..398e0b7b47 100644 --- a/frontend/src/core/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/core/components/tools/compress/CompressSettings.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core"; +import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters"; import ButtonSelector from "@app/components/shared/ButtonSelector"; @@ -123,18 +123,12 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C {/* Compression Options */} - + onParameterChange('grayscale', event.currentTarget.checked)} + disabled={disabled} + label={t("compress.grayscale.label", "Apply Grayscale for compression")} + />
); diff --git a/frontend/src/proprietary/routes/signup/SignupForm.tsx b/frontend/src/proprietary/routes/signup/SignupForm.tsx index 35caf77644..9e6ac9dc06 100644 --- a/frontend/src/proprietary/routes/signup/SignupForm.tsx +++ b/frontend/src/proprietary/routes/signup/SignupForm.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import '@app/routes/authShared/auth.css'; import { useTranslation } from 'react-i18next'; +import { Checkbox } from '@mantine/core'; import { SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'; interface SignupFormProps { @@ -133,19 +134,20 @@ export default function SignupForm({ {/* Terms - only show if showTerms is true */} {showTerms && (
- setAgree?.(e.target.checked)} + onChange={(e) => setAgree?.(e.currentTarget.checked)} className="auth-checkbox" + label={ + + {t("legal.iAgreeToThe", 'I agree to all of the')}{' '} + + {t('legal.terms', 'Terms and Conditions')} + + + } /> -
)} From d06391a9275007736cd444f0d857244741793e2b Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 13 Nov 2025 12:11:59 +0000 Subject: [PATCH 08/16] Redesign and enable macOS signing (#4883) # Description of Changes Re-enable macOS signing and tweak so it runs successfully through CI. Also changes the runner to use macOS 15 instead of 13, which was throwing a deprecation warning in GitHub. Note that the runner doesn't affect the minimum target, which I've still got set to 10.15 (no idea if it actually works on 10.15 but let's assume that if it builds it works until someone can test it) --- .github/workflows/tauri-build.yml | 147 ++++++++++++++++------------- frontend/src-tauri/tauri.conf.json | 6 ++ 2 files changed, 86 insertions(+), 67 deletions(-) diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index d56c462d05..077704d235 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -47,21 +47,19 @@ jobs: "windows") echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT ;; - # "macos") - # echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT - # ;; + "macos") + echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT + ;; "linux") echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; *) - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT - # Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"} + echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT ;; esac else # For PR/push events, build all platforms - echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT - # Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"} + echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT fi build: @@ -96,7 +94,7 @@ jobs: uses: dtolnay/rust-toolchain@stable with: toolchain: stable - targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} @@ -183,80 +181,96 @@ jobs: working-directory: ./frontend run: npm install - # Disabled Mac builds - Import Apple Developer Certificate - # - name: Import Apple Developer Certificate - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # env: - # APPLE_ID: ${{ secrets.APPLE_ID }} - # APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - # run: | - # echo "Importing Apple Developer Certificate..." - # echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 - # security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # security default-keychain -s build.keychain - # security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - # security set-keychain-settings -t 3600 -u build.keychain - # security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - # security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain - # security find-identity -v -p codesigning build.keychain - # - name: Verify Certificate - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # run: | - # echo "Verifying Apple Developer Certificate..." - # CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - # echo "Certificate Info: $CERT_INFO" - # CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - # echo "Certificate ID: $CERT_ID" - # echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - # echo "Certificate imported." + - name: Import Apple Developer Certificate + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + echo "Importing Apple Developer Certificate..." + echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # Import certificate + security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + # Clean up + rm certificate.p12 - # - name: Check DMG creation dependencies (macOS only) - # if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13' - # run: | - # echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..." - # echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')" - # echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')" - # echo "Available disk space: $(df -h /tmp | tail -1)" - # echo "macOS version: $(sw_vers -productVersion)" - # echo "Available tools:" - # ls -la /usr/bin/hd* || echo "No hd* tools found" + - name: Verify Certificate + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "Verifying Apple Developer Certificate..." + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application") + echo "Certificate Info: $CERT_INFO" + CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') + echo "Certificate ID: $CERT_ID" + echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV + echo "Certificate imported successfully." - - name: Build Tauri app + - name: Check DMG creation dependencies (macOS only) + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..." + echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')" + echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')" + echo "Available disk space: $(df -h /tmp | tail -1)" + echo "macOS version: $(sw_vers -productVersion)" + echo "Available tools:" + ls -la /usr/bin/hd* || echo "No hd* tools found" + + - name: Build Tauri app uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }} SIGN: 1 - CI: true + CI: true with: projectPath: ./frontend tauriScript: npx tauri args: ${{ matrix.args }} - + + - name: Verify notarization (macOS only) + if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel' + run: | + echo "🔍 Verifying notarization status..." + cd ./frontend/src-tauri/target + DMG_FILE=$(find . -name "*.dmg" | head -1) + if [ -n "$DMG_FILE" ]; then + echo "Found DMG: $DMG_FILE" + echo "Checking notarization ticket..." + spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete" + stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached" + else + echo "⚠️ No DMG file found to verify" + fi + - name: Rename artifacts shell: bash run: | mkdir -p ./dist cd ./frontend/src-tauri/target - + # Find and rename artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \; find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \; - # Disabled Mac builds - # elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then - # find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; - # find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \; + elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then + find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \; + find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \; else find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \; find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \; @@ -273,7 +287,7 @@ jobs: shell: bash run: | cd ./frontend/src-tauri/target - + # Check for expected artifacts based on platform if [ "${{ matrix.platform }}" = "windows-latest" ]; then echo "Checking for Windows artifacts..." @@ -282,14 +296,13 @@ jobs: echo "❌ No Windows executable found" exit 1 fi - # Disabled Mac builds - # elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then - # echo "Checking for macOS artifacts..." - # find . -name "*.dmg" -o -name "*.app" | head -5 - # if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then - # echo "❌ No macOS artifacts found" - # exit 1 - # fi + elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then + echo "Checking for macOS artifacts..." + find . -name "*.dmg" -o -name "*.app" | head -5 + if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then + echo "❌ No macOS artifacts found" + exit 1 + fi else echo "Checking for Linux artifacts..." find . -name "*.deb" -o -name "*.AppImage" | head -5 @@ -298,7 +311,7 @@ jobs: exit 1 fi fi - + echo "✅ Build artifacts found for ${{ matrix.name }}" - name: Test artifact sizes diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index d845a2dc97..a6dffc8812 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -50,6 +50,12 @@ "deb": { "desktopTemplate": "stirling-pdf.desktop" } + }, + "macOS": { + "minimumSystemVersion": "10.15", + "signingIdentity": null, + "entitlements": null, + "providerShortName": null } }, "plugins": { From aa20dbb7a6e4dbab7e7b963c994252431e6359f4 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 13 Nov 2025 12:53:57 +0000 Subject: [PATCH 09/16] Feature/v2/selected pageeditor rework (#4756) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/package-lock.json | 58 + frontend/package.json | 1 + .../public/locales/en-GB/translation.json | 6 +- frontend/src/core/components/AppProviders.tsx | 21 +- .../components/layout/Workbench.module.css | 10 +- .../src/core/components/layout/Workbench.tsx | 7 +- .../pageEditor/DragDropGrid.module.css | 72 ++ .../components/pageEditor/DragDropGrid.tsx | 804 +++++++++++-- .../pageEditor/PageEditor.module.css | 42 +- .../core/components/pageEditor/PageEditor.tsx | 1003 ++++++----------- .../pageEditor/PageEditorControls.tsx | 14 +- .../components/pageEditor/PageThumbnail.tsx | 180 +-- .../pageEditor/commands/pageCommands.ts | 55 +- .../core/components/pageEditor/fileColors.ts | 61 + .../hooks/useEditedDocumentState.ts | 249 ++++ .../pageEditor/hooks/useEditorCommands.ts | 420 +++++++ .../pageEditor/hooks/useFileColorMap.ts | 33 + .../hooks/useInitialPageDocument.ts | 22 + .../pageEditor/hooks/usePageDocument.ts | 117 +- .../hooks/usePageEditorDropdownState.ts | 65 ++ .../pageEditor/hooks/usePageEditorExport.ts | 313 +++++ .../pageEditor/hooks/usePageEditorState.ts | 13 +- .../hooks/usePageSelectionManager.ts | 137 +++ .../pageEditor/hooks/useUndoManagerState.ts | 62 + .../pageEditor/pageEditorRightRailButtons.tsx | 16 + .../core/components/shared/AppConfigModal.tsx | 2 - .../shared/HoverActionMenu.module.css | 1 - .../components/shared/HoverActionMenu.tsx | 5 +- .../shared/PageEditorFileDropdown.tsx | 234 ++++ .../src/core/components/shared/Tooltip.tsx | 1 + .../core/components/shared/TopControls.tsx | 123 +- .../shared/pageEditor/useFileItemDragDrop.ts | 154 +++ .../components/tools/compare/compareView.css | 1 + .../tools/overlayPdfs/OverlayPdfsSettings.tsx | 2 - .../tooltips/useSplitSettingsTips.ts | 2 +- .../core/components/viewer/EmbedPdfViewer.tsx | 39 +- .../src/core/contexts/PageEditorContext.tsx | 359 ++++++ .../src/core/contexts/file/FileReducer.ts | 2 + frontend/src/core/hooks/useWheelZoom.ts | 83 ++ frontend/src/core/styles/zIndex.ts | 6 +- frontend/src/core/tools/Split.tsx | 5 +- frontend/src/core/types/fileContext.ts | 13 +- frontend/src/core/types/pageEditor.ts | 10 + 43 files changed, 3833 insertions(+), 990 deletions(-) create mode 100644 frontend/src/core/components/pageEditor/DragDropGrid.module.css create mode 100644 frontend/src/core/components/pageEditor/fileColors.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useFileColorMap.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts create mode 100644 frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts create mode 100644 frontend/src/core/components/shared/PageEditorFileDropdown.tsx create mode 100644 frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts create mode 100644 frontend/src/core/contexts/PageEditorContext.tsx create mode 100644 frontend/src/core/hooks/useWheelZoom.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index deb9b1a058..407f8ca066 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", + "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", @@ -505,6 +506,63 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/accessibility/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@embedpdf/core": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index b0989a0dff..3fa614f359 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", + "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^1.4.1", "@embedpdf/engines": "^1.4.1", "@embedpdf/plugin-annotation": "^1.4.1", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index aae5bbc9ed..2665b34200 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1011,7 +1011,8 @@ "title": "Choose Your Split Method" } }, - "selectMethod": "Select a split method" + "selectMethod": "Select a split method", + "resultsTitle": "Split Results" }, "rotate": { "title": "Rotate PDF", @@ -3605,7 +3606,8 @@ "toggleAnnotations": "Toggle Annotations Visibility", "annotationMode": "Toggle Annotation Mode", "draw": "Draw", - "save": "Save" + "save": "Save", + "saveChanges": "Save Changes" }, "search": { "title": "Search PDF", diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 3bf96f29f3..24f7931884 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -15,6 +15,7 @@ import { SignatureProvider } from "@app/contexts/SignatureContext"; 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 ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -64,15 +65,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - - {children} - - - - + + + + + + {children} + + + + + diff --git a/frontend/src/core/components/layout/Workbench.module.css b/frontend/src/core/components/layout/Workbench.module.css index 602110e4bd..73d2e402f4 100644 --- a/frontend/src/core/components/layout/Workbench.module.css +++ b/frontend/src/core/components/layout/Workbench.module.css @@ -1,21 +1,21 @@ -.workbench-scrollable { +.workbenchScrollable { overflow-y: auto !important; overflow-x: hidden !important; } -.workbench-scrollable::-webkit-scrollbar { +.workbenchScrollable::-webkit-scrollbar { width: 0.375rem; } -.workbench-scrollable::-webkit-scrollbar-track { +.workbenchScrollable::-webkit-scrollbar-track { background: transparent; } -.workbench-scrollable::-webkit-scrollbar-thumb { +.workbenchScrollable::-webkit-scrollbar-thumb { background-color: var(--mantine-color-gray-4); border-radius: 0.1875rem; } -.workbench-scrollable::-webkit-scrollbar-thumb:hover { +.workbenchScrollable::-webkit-scrollbar-thumb:hover { background-color: var(--mantine-color-gray-5); } diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 4b3d1ebc80..f6477c67aa 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Box } from '@mantine/core'; import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; @@ -187,18 +186,16 @@ export default function Workbench() { {/* Main content area */} 0 ? '3.5rem' : '0'), - overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined, }} > {renderMainContent()}
{ items: T[]; - selectedItems: string[]; - selectionMode: boolean; - isAnimating: boolean; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; - renderItem: (item: T, index: number, refs: React.MutableRefObject>) => React.ReactNode; - renderSplitMarker?: (item: T, index: number) => React.ReactNode; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode; + getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; + zoomLevel?: number; + selectedFileIds?: string[]; } +type DropSide = 'left' | 'right' | null; + +type ItemRect = { id: string; rect: DOMRect }; + +interface DropHint { + hoveredId: string | null; + dropSide: DropSide; +} + +function resolveDropHint( + activeId: string | null, + itemRefs: React.MutableRefObject>, + cursorX: number, + cursorY: number, +): DropHint { + if (!activeId) { + return { hoveredId: null, dropSide: null }; + } + + const items: ItemRect[] = Array.from(itemRefs.current.entries()) + .filter((entry): entry is [string, HTMLDivElement] => !!entry[1] && entry[0] !== activeId) + .map(([itemId, element]) => ({ + id: itemId, + rect: element.getBoundingClientRect(), + })) + .filter(({ rect }) => rect.width > 0 && rect.height > 0); + + if (items.length === 0) { + return { hoveredId: null, dropSide: null }; + } + + items.sort((a, b) => a.rect.top - b.rect.top); + + const rows: ItemRect[][] = []; + const rowTolerance = items[0].rect.height / 2; + + items.forEach((item) => { + const currentRow = rows[rows.length - 1]; + if (!currentRow) { + rows.push([item]); + return; + } + + const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance; + if (isSameRow) { + currentRow.push(item); + } else { + rows.push([item]); + } + }); + + let targetRow: ItemRect[] | undefined; + let smallestRowDistance = Infinity; + + rows.forEach((row) => { + if (row.length === 0) { + return; + } + const top = row[0].rect.top; + const bottom = row[0].rect.bottom; + const centerY = top + (bottom - top) / 2; + const distance = Math.abs(cursorY - centerY); + if (distance < smallestRowDistance) { + smallestRowDistance = distance; + targetRow = row; + } + }); + + if (!targetRow || targetRow.length === 0) { + return { hoveredId: null, dropSide: null }; + } + + let hoveredItem = targetRow[0]; + let smallestHorizontalDistance = Infinity; + + targetRow.forEach((item) => { + const midpoint = item.rect.left + item.rect.width / 2; + const distance = Math.abs(cursorX - midpoint); + if (distance < smallestHorizontalDistance) { + smallestHorizontalDistance = distance; + hoveredItem = item; + } + }); + + const firstItem = targetRow[0]; + const lastItem = targetRow[targetRow.length - 1]; + + let dropSide: DropSide; + if (cursorX < firstItem.rect.left) { + hoveredItem = firstItem; + dropSide = 'left'; + } else if (cursorX > lastItem.rect.right) { + hoveredItem = lastItem; + dropSide = 'right'; + } else { + const midpoint = hoveredItem.rect.left + hoveredItem.rect.width / 2; + dropSide = cursorX >= midpoint ? 'right' : 'left'; + } + + return { hoveredId: hoveredItem.id, dropSide }; +} + +function resolveTargetIndex( + hoveredId: string | null, + dropSide: DropSide, + filteredItems: T[], + filteredToOriginalIndex: number[], + originalItemsLength: number, + fallbackIndex: number | null, +): number | null { + const convertFilteredIndexToOriginal = (filteredIndex: number): number => { + if (filteredToOriginalIndex.length === 0) { + return 0; + } + + if (filteredIndex <= 0) { + return filteredToOriginalIndex[0]; + } + + if (filteredIndex >= filteredToOriginalIndex.length) { + return originalItemsLength; + } + + return filteredToOriginalIndex[filteredIndex]; + }; + + if (hoveredId) { + const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId); + if (filteredIndex !== -1) { + const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0); + return convertFilteredIndexToOriginal(adjustedIndex); + } + } + + if (fallbackIndex !== null && fallbackIndex !== undefined) { + const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0); + return convertFilteredIndexToOriginal(adjustedIndex); + } + + return null; +} + +// Lightweight wrapper that handles dnd-kit hooks for each visible item +interface DraggableItemProps { + item: T; + index: number; + itemRefs: React.MutableRefObject>; + boxSelectedPageIds: string[]; + clearBoxSelection: () => void; + getBoxSelection: () => string[]; + activeId: string | null; + activeDragIds: string[]; + justMoved: boolean; + getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; + onUpdateDropTarget: (itemId: string | null) => void; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode; + zoomLevel: number; +} + +const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps) => { + const isPlaceholder = Boolean(item.isPlaceholder); + const pageNumber = (item as any).pageNumber ?? index + 1; + const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ + id: item.id, + disabled: isPlaceholder, + data: { + index, + pageNumber, + getThumbnail: () => { + if (getThumbnailData) { + const data = getThumbnailData(item.id); + if (data?.src) return data; + } + + const element = itemRefs.current.get(item.id); + const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement; + if (imgElement?.src) { + return { + src: imgElement.src, + rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0 + }; + } + return null; + } + } + }); + + const { setNodeRef: setDroppableRef, isOver } = useDroppable({ + id: item.id, + data: { index, pageNumber: index + 1 } + }); + + // Notify parent when hover state changes + useEffect(() => { + if (isOver) { + onUpdateDropTarget(item.id); + } else { + onUpdateDropTarget(null); + } + }, [isOver, item.id, onUpdateDropTarget]); + + const setNodeRef = useCallback((element: HTMLDivElement | null) => { + setDraggableRef(element); + setDroppableRef(element); + }, [setDraggableRef, setDroppableRef]); + + return ( + <> + {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)} + + ); +}; + const DragDropGrid = ({ items, renderItem, + onReorderPages, + getThumbnailData, + zoomLevel = 1.0, + selectedFileIds, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); const containerRef = useRef(null); + const getScrollElement = useCallback(() => { + return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null; + }, []); + + const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => { + const filtered: T[] = []; + const indexMap: number[] = []; + const selectedIds = + selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null; + + items.forEach((item, index) => { + const isPlaceholder = Boolean(item.isPlaceholder); + if (isPlaceholder) { + return; + } + + const belongsToVisibleFile = + !selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId); + + if (!belongsToVisibleFile) { + return; + } + + filtered.push(item); + indexMap.push(index); + }); + + return { filteredItems: filtered, filteredToOriginalIndex: indexMap }; + }, [items, selectedFileIds]); + + useEffect(() => { + const visibleIdSet = new Set(visibleItems.map(item => item.id)); + itemRefs.current.forEach((_, pageId) => { + if (!visibleIdSet.has(pageId)) { + itemRefs.current.delete(pageId); + } + }); + }, [visibleItems]); + + // Box selection state + const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null); + const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null); + const [isBoxSelecting, setIsBoxSelecting] = useState(false); + const [boxSelectedPageIds, setBoxSelectedPageIds] = useState([]); + const [justMovedIds, setJustMovedIds] = useState([]); + const highlightTimeoutRef = useRef(null); + + // Drag state + const [activeId, setActiveId] = useState(null); + const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null); + const [hoveredItemId, setHoveredItemId] = useState(null); + const [dropSide, setDropSide] = useState(null); + + // Configure sensors for dnd-kit with activation constraint + // Require 10px movement before drag starts to allow clicks for selection + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 10, + }, + }) + ); + + // Throttled pointer move handler for drop indicator + // Calculate drop position based on cursor location relative to ALL items, not just hovered item + useEffect(() => { + if (!activeId) { + setDropSide(null); + setHoveredItemId(null); + return; + } + + let rafId: number | null = null; + + const handlePointerMove = (e: PointerEvent) => { + // Use the actual cursor position (pointer coordinates) + const cursorX = e.clientX; + const cursorY = e.clientY; + + if (rafId === null) { + rafId = requestAnimationFrame(() => { + const hint = resolveDropHint(activeId, itemRefs, cursorX, cursorY); + setHoveredItemId(hint.hoveredId); + setDropSide(hint.dropSide); + rafId = null; + }); + } + }; + + window.addEventListener('pointermove', handlePointerMove, { passive: true }); + return () => { + window.removeEventListener('pointermove', handlePointerMove); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + }; + }, [activeId]); + // Responsive grid configuration const [itemsPerRow, setItemsPerRow] = useState(4); - const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL; + const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL; // Calculate items per row based on container width const calculateItemsPerRow = useCallback(() => { @@ -38,8 +374,8 @@ const DragDropGrid = ({ // Convert rem to pixels for calculation const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); - const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; - const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel; + const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel; // Calculate how many items fit: (width - gap) / (itemWidth + gap) const availableWidth = containerWidth - ITEM_GAP; // Account for first gap @@ -47,9 +383,9 @@ const DragDropGrid = ({ const calculated = Math.floor(availableWidth / itemWithGap); return Math.max(1, calculated); // At least 1 item per row - }, []); + }, [zoomLevel]); - // Update items per row when container resizes + // Update items per row when container resizes or zoom changes useEffect(() => { const updateLayout = () => { const newItemsPerRow = calculateItemsPerRow(); @@ -72,86 +408,410 @@ const DragDropGrid = ({ window.removeEventListener('resize', updateLayout); resizeObserver.disconnect(); }; - }, [calculateItemsPerRow]); + }, [calculateItemsPerRow, zoomLevel]); // Virtualization with react-virtual library const rowVirtualizer = useVirtualizer({ - count: Math.ceil(items.length / itemsPerRow), - getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element, + count: Math.ceil(visibleItems.length / itemsPerRow), + getScrollElement, estimateSize: () => { const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); - return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; + return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel; }, overscan: OVERSCAN, }); + // Re-measure virtualizer when zoom or items per row changes + useEffect(() => { + rowVirtualizer.measure(); + }, [zoomLevel, itemsPerRow, visibleItems.length]); + + // Cleanup highlight timeout on unmount + useEffect(() => { + return () => { + if (highlightTimeoutRef.current) { + window.clearTimeout(highlightTimeoutRef.current); + highlightTimeoutRef.current = null; + } + }; + }, []); + + // Box selection handlers + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; // Only respond to primary button + + const container = containerRef.current; + if (!container) return; + + const clickTarget = e.target as Node; + let clickedPageId: string | null = null; + + itemRefs.current.forEach((element, pageId) => { + if (element.contains(clickTarget)) { + clickedPageId = pageId; + } + }); + + if (clickedPageId) { + // Clicking directly on a page shouldn't initiate box selection + // but clear previous box selection if clicking outside current group + if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) { + setBoxSelectedPageIds([]); + } + return; + } + + e.preventDefault(); + + const rect = container.getBoundingClientRect(); + setIsBoxSelecting(true); + setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + setBoxSelectedPageIds([]); + }, [boxSelectedPageIds]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (!isBoxSelecting || !boxSelectStart) return; + + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + + setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + + // Calculate which pages intersect with selection box + const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left); + const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left); + const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top); + const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top); + + const selectedIds: string[] = []; + itemRefs.current.forEach((pageEl, pageId) => { + const pageRect = pageEl.getBoundingClientRect(); + const pageLeft = pageRect.left - rect.left; + const pageRight = pageRect.right - rect.left; + const pageTop = pageRect.top - rect.top; + const pageBottom = pageRect.bottom - rect.top; + + // Check if page intersects with selection box + const intersects = !( + pageRight < boxLeft || + pageLeft > boxRight || + pageBottom < boxTop || + pageTop > boxBottom + ); + + if (intersects) { + selectedIds.push(pageId); + } + }); + + setBoxSelectedPageIds(selectedIds); + }, [isBoxSelecting, boxSelectStart]); + + const handleMouseUp = useCallback(() => { + if (isBoxSelecting) { + // Keep box-selected pages highlighted (don't clear boxSelectedPageIds yet) + // They will remain highlighted until next interaction + setIsBoxSelecting(false); + setBoxSelectStart(null); + setBoxSelectEnd(null); + } + }, [isBoxSelecting]); + + // Function to clear box selection (exposed to child components) + const clearBoxSelection = useCallback(() => { + setBoxSelectedPageIds([]); + }, []); + + // Function to get current box selection (exposed to child components) + const getBoxSelection = useCallback(() => { + return boxSelectedPageIds; + }, [boxSelectedPageIds]); + + // Handle drag start + const handleDragStart = useCallback((event: DragStartEvent) => { + const activeId = event.active.id as string; + setActiveId(activeId); + + // Call the getter function to get fresh thumbnail data + const getThumbnail = event.active.data.current?.getThumbnail; + if (getThumbnail) { + const thumbnailData = getThumbnail(); + if (thumbnailData?.src) { + setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation }); + return; + } + } + + setDragPreview(null); + }, []); + + + // Handle drag cancel + const handleDragCancel = useCallback(() => { + setActiveId(null); + setDragPreview(null); + setHoveredItemId(null); + setDropSide(null); + }, []); + + // Handle drag end + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + const finalDropSide = dropSide; + setActiveId(null); + setDragPreview(null); + setHoveredItemId(null); + setDropSide(null); + + if (!over || active.id === over.id) { + return; + } + + const activeData = active.data.current; + if (!activeData) { + return; + } + + const sourcePageNumber = activeData.pageNumber; + + const overData = over?.data.current; + let targetIndex = resolveTargetIndex( + hoveredItemId, + finalDropSide, + visibleItems, + filteredToOriginalIndex, + items.length, + overData ? overData.index : null + ); + + if (targetIndex === null) { + return; + } + if (targetIndex < 0) targetIndex = 0; + if (targetIndex > items.length) targetIndex = items.length; + + // Check if this page is box-selected + const isBoxSelected = boxSelectedPageIds.includes(active.id as string); + const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined; + + // Call reorder with page number and target index + onReorderPages(sourcePageNumber, targetIndex, pagesToDrag); + + // Highlight moved pages briefly + const movedIds = pagesToDrag ?? [active.id as string]; + setJustMovedIds(movedIds); + if (highlightTimeoutRef.current) { + window.clearTimeout(highlightTimeoutRef.current); + } + highlightTimeoutRef.current = window.setTimeout(() => { + setJustMovedIds([]); + highlightTimeoutRef.current = null; + }, 1200); + + // Clear box selection after drag + if (pagesToDrag) { + clearBoxSelection(); + } + }, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]); + // Calculate optimal width for centering const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); - const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; - const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel; + const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel; const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap; - return ( - -
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const startIndex = virtualRow.index * itemsPerRow; - const endIndex = Math.min(startIndex + itemsPerRow, items.length); - const rowItems = items.slice(startIndex, endIndex); + // Calculate selection box dimensions + const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? { + left: Math.min(boxSelectStart.x, boxSelectEnd.x), + top: Math.min(boxSelectStart.y, boxSelectEnd.y), + width: Math.abs(boxSelectEnd.x - boxSelectStart.x), + height: Math.abs(boxSelectEnd.y - boxSelectStart.y), + zIndex: Z_INDEX_SELECTION_BOX, + } : null; - return ( -
+ // Calculate drop indicator position + const dropIndicatorStyle = useMemo(() => { + if (!hoveredItemId || !dropSide || !activeId) return null; + + const element = itemRefs.current.get(hoveredItemId); + const container = containerRef.current; + if (!element || !container) return null; + + const itemRect = element.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + const top = itemRect.top - containerRect.top; + const height = itemRect.height; + const left = dropSide === 'left' + ? itemRect.left - containerRect.left - itemGap / 2 + : itemRect.right - containerRect.left + itemGap / 2; + + return { + left: `${left}px`, + top: `${top}px`, + height: `${height}px`, + zIndex: Z_INDEX_DROP_INDICATOR, + }; + }, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]); + + const activeDragIds = useMemo(() => { + if (!activeId) return []; + if (boxSelectedPageIds.includes(activeId)) { + return boxSelectedPageIds; + } + return [activeId]; + }, [activeId, boxSelectedPageIds]); + + const handleWheelWhileDragging = useCallback((event: React.WheelEvent) => { + if (!activeId) { + return; + } + + const scrollElement = getScrollElement(); + if (!scrollElement) { + return; + } + + scrollElement.scrollBy({ + top: event.deltaY, + left: event.deltaX, + }); + + event.preventDefault(); + }, [activeId, getScrollElement]); + + return ( + + + {selectionBoxStyle && ( +
+ )} + + {dropIndicatorStyle && ( +
+ )} + +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * itemsPerRow; + const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length); + const rowItems = visibleItems.slice(startIndex, endIndex); + + return (
- {rowItems.map((item, itemIndex) => { - const actualIndex = startIndex + itemIndex; - return ( - - {/* Item */} - {renderItem(item, actualIndex, itemRefs)} - - ); - })} - +
+ {rowItems.map((item, itemIndex) => { + const actualIndex = startIndex + itemIndex; + return ( + + ); + })} +
-
- ); - })} -
- + ); + })} +
+
+ + {/* Drag Overlay */} + + {activeId && ( +
+ {boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && ( +
+ {boxSelectedPageIds.length} +
+ )} + {dragPreview ? ( + Dragging + ) : ( +
+ +
+ )} +
+ )} +
+
); }; diff --git a/frontend/src/core/components/pageEditor/PageEditor.module.css b/frontend/src/core/components/pageEditor/PageEditor.module.css index ab2fd691b6..0c295a209c 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.module.css +++ b/frontend/src/core/components/pageEditor/PageEditor.module.css @@ -11,6 +11,26 @@ transform: scale(1.02) translateZ(0); } +.pageSurface { + transition: background-color 0.4s ease; +} + +.pageJustMoved { + animation: pageMovedHighlight 1.2s ease-out; +} + +@keyframes pageMovedHighlight { + 0% { + background-color: rgba(59, 130, 246, 0.32); + } + 60% { + background-color: rgba(59, 130, 246, 0.12); + } + 100% { + background-color: rgba(59, 130, 246, 0); + } +} + .pageContainer:hover .pageNumber { opacity: 1 !important; } @@ -30,28 +50,6 @@ transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); } -.pageMoving { - z-index: 10; - transform: scale(1.05); - box-shadow: 0 10px 30px rgba(0,0,0,0.3); -} - -/* Multi-page drag indicator */ -.multiDragIndicator { - position: fixed; - background: rgba(59, 130, 246, 0.9); - color: white; - padding: 8px 12px; - border-radius: 20px; - font-size: 12px; - font-weight: 600; - pointer-events: none; - z-index: 1000; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - transform: translate(-50%, -50%); - backdrop-filter: blur(4px); -} - /* Animations */ @keyframes pulse { 0%, 100% { diff --git a/frontend/src/core/components/pageEditor/PageEditor.tsx b/frontend/src/core/components/pageEditor/PageEditor.tsx index 079ea747e4..b68cdac3ca 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/src/core/components/pageEditor/PageEditor.tsx @@ -1,12 +1,9 @@ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { useFileState, useFileActions } from "@app/contexts/FileContext"; import { useNavigationGuard } from "@app/contexts/NavigationContext"; -import { PDFDocument, PageEditorFunctions } from "@app/types/pageEditor"; -import { pdfExportService } from "@app/services/pdfExportService"; -import { documentManipulationService } from "@app/services/documentManipulationService"; -import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers"; -import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; +import { usePageEditor } from "@app/contexts/PageEditorContext"; +import { PageEditorFunctions } from "@app/types/pageEditor"; // Thumbnail generation is now handled by individual PageThumbnail components import '@app/components/pageEditor/PageEditor.module.css'; import PageThumbnail from '@app/components/pageEditor/PageThumbnail'; @@ -14,20 +11,18 @@ import DragDropGrid from '@app/components/pageEditor/DragDropGrid'; import SkeletonLoader from '@app/components/shared/SkeletonLoader'; import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; import { FileId } from "@app/types/file"; - -import { - DeletePagesCommand, - ReorderPagesCommand, - SplitCommand, - BulkRotateCommand, - PageBreakCommand, - UndoManager -} from '@app/components/pageEditor/commands/pageCommands'; import { GRID_CONSTANTS } from '@app/components/pageEditor/constants'; +import { useInitialPageDocument } from '@app/components/pageEditor/hooks/useInitialPageDocument'; import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument'; import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState'; -import { parseSelection } from "@app/utils/bulkselection/parseSelection"; import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons"; +import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap"; +import { useWheelZoom } from "@app/hooks/useWheelZoom"; +import { useEditedDocumentState } from "@app/components/pageEditor/hooks/useEditedDocumentState"; +import { useUndoManagerState } from "@app/components/pageEditor/hooks/useUndoManagerState"; +import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePageSelectionManager"; +import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands"; +import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport"; export interface PageEditorProps { onFunctionsReady?: (functions: PageEditorFunctions) => void; @@ -44,22 +39,115 @@ const PageEditor = ({ // Navigation guard for unsaved changes const { setHasUnsavedChanges } = useNavigationGuard(); - // Prefer IDs + selectors to avoid array identity churn - const activeFileIds = state.files.ids; + // Get PageEditor coordination functions + const { updateFileOrderFromPages, fileOrder, reorderedPages, clearReorderedPages, updateCurrentPages } = usePageEditor(); + + // Zoom state management + const [zoomLevel, setZoomLevel] = useState(1.0); + const containerRef = useRef(null); + const [isContainerHovered, setIsContainerHovered] = useState(false); + const rootFontSize = useMemo(() => { + if (typeof window === 'undefined') { + return 16; + } + const computed = getComputedStyle(document.documentElement).fontSize; + const parsed = parseFloat(computed); + return Number.isNaN(parsed) ? 16 : parsed; + }, []); + const itemGapPx = useMemo(() => { + return parseFloat(GRID_CONSTANTS.ITEM_GAP) * rootFontSize * zoomLevel; + }, [rootFontSize, zoomLevel]); + + // Zoom actions + const zoomIn = useCallback(() => { + setZoomLevel(prev => Math.min(prev + 0.1, 3.0)); + }, []); + + const zoomOut = useCallback(() => { + setZoomLevel(prev => Math.max(prev - 0.1, 0.5)); + }, []); + + // Derive page editor files from PageEditorContext's fileOrder (page editor workspace order) + // Filter to only show PDF files (PageEditor only supports PDFs) + // Use stable string keys to prevent infinite loops + // Cache file objects to prevent infinite re-renders from new object references + const fileOrderKey = fileOrder.join(','); + const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(','); + const filesSignature = selectors.getFilesSignature(); + + const fileObjectsRef = useRef(new Map()); + const gridItemRefsRef = useRef> | null>(null); + + const pageEditorFiles = useMemo(() => { + const cache = fileObjectsRef.current; + const newFiles: any[] = []; + + fileOrder.forEach(fileId => { + const stub = selectors.getStirlingFileStub(fileId); + const isSelected = state.ui.selectedFileIds.includes(fileId); + const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false; + + if (!isPdf) return; // Skip non-PDFs + + const cached = cache.get(fileId); + + // Check if data actually changed (compare by fileId, not position) + if (cached && + cached.fileId === fileId && + cached.name === (stub?.name || '') && + cached.versionNumber === stub?.versionNumber && + cached.isSelected === isSelected) { + // Reuse existing object reference + newFiles.push(cached); + } else { + // Create new object only if data changed + const newFile = { + fileId, + name: stub?.name || '', + versionNumber: stub?.versionNumber, + isSelected, + }; + cache.set(fileId, newFile); + newFiles.push(newFile); + } + }); + + // Clean up removed files from cache + const activeIds = new Set(newFiles.map(f => f.fileId)); + for (const cachedId of cache.keys()) { + if (!activeIds.has(cachedId)) { + cache.delete(cachedId); + } + } + + return newFiles; + }, [fileOrderKey, selectedIdsKey, filesSignature]); + + // Get ALL file IDs in order (not filtered by selection) + const orderedFileIds = useMemo(() => { + return pageEditorFiles.map(f => f.fileId); + }, [pageEditorFiles]); + + // Get selected file IDs for filtering + const selectedFileIds = useMemo(() => { + return pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId); + }, [pageEditorFiles]); const activeFilesSignature = selectors.getFilesSignature(); // UI state const globalProcessing = state.ui.isProcessing; - // Edit state management - const [editedDocument, setEditedDocument] = useState(null); - - // DOM-first undo manager (replaces the old React state undo system) - const undoManagerRef = useRef(new UndoManager()); - - // Document state management + const initialDocument = useInitialPageDocument(); const { document: mergedPdfDocument } = usePageDocument(); + const { setEditedDocument, displayDocument, getEditedDocument } = useEditedDocumentState({ + initialDocument, + mergedPdfDocument, + reorderedPages, + clearReorderedPages, + fileOrder, + updateCurrentPages, + }); // UI state management const { @@ -68,563 +156,84 @@ const PageEditor = ({ togglePage, toggleSelectAll, animateReorder } = usePageEditorState(); - const [csvInput, setCsvInput] = useState(''); - - useEffect(() => { - setCsvInput(''); - }, [activeFilesSignature]); + const { + csvInput, + setCsvInput, + totalPages, + getPageNumbersFromIds, + handleSelectAll, + handleDeselectAll, + handleSetSelectedPages, + updatePagesFromCSV, + } = usePageSelectionManager({ + displayDocument, + selectedPageIds, + setSelectedPageIds, + setSelectionMode, + toggleSelectAll, + activeFilesSignature, + }); // Grid container ref for positioning split indicators const gridContainerRef = useRef(null); - // State to trigger re-renders when container size changes - const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); + const { + canUndo, + canRedo, + executeCommandWithTracking, + handleUndo, + handleRedo, + clearUndoHistory, + } = useUndoManagerState({ setHasUnsavedChanges }); - // Undo/Redo state - const [canUndo, setCanUndo] = useState(false); - const [canRedo, setCanRedo] = useState(false); + const { + createRotateCommand, + createDeleteCommand, + createSplitCommand, + executeCommand, + handleRotate, + handleDelete, + handleDeletePage, + handleSplit, + handleSplitAll, + handlePageBreak, + handlePageBreakAll, + handleInsertFiles, + handleReorderPages, + closePdf, + } = usePageEditorCommands({ + displayDocument, + getEditedDocument, + setEditedDocument, + splitPositions, + setSplitPositions, + selectedPageIds, + setSelectedPageIds, + getPageNumbersFromIds, + executeCommandWithTracking, + updateFileOrderFromPages, + actions, + selectors, + setSelectionMode, + clearUndoHistory, + }); - // Update undo/redo state - const updateUndoRedoState = useCallback(() => { - setCanUndo(undoManagerRef.current.canUndo()); - setCanRedo(undoManagerRef.current.canRedo()); - }, []); + const { onExportSelected, onExportAll, applyChanges } = usePageEditorExport({ + displayDocument, + selectedPageIds, + splitPositions, + selectedFileIds, + selectors, + actions, + setHasUnsavedChanges, + exportLoading, + setExportLoading, + setSplitPositions, + }); - // Set up undo manager callback - useEffect(() => { - undoManagerRef.current.setStateChangeCallback(updateUndoRedoState); - // Initialize state - updateUndoRedoState(); - }, [updateUndoRedoState]); - - // Wrapper for executeCommand to track unsaved changes - const executeCommandWithTracking = useCallback((command: any) => { - undoManagerRef.current.executeCommand(command); - setHasUnsavedChanges(true); - }, [setHasUnsavedChanges]); - - // Watch for container size changes to update split line positions - useEffect(() => { - const container = gridContainerRef.current; - if (!container) return; - - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (entry) { - setContainerDimensions({ - width: entry.contentRect.width, - height: entry.contentRect.height - }); - } - }); - - resizeObserver.observe(container); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - // Interface functions for parent component - const displayDocument = editedDocument || mergedPdfDocument; - const totalPages = displayDocument?.pages.length ?? 0; + // Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument) const selectedPageCount = selectedPageIds.length; - - // Utility functions to convert between page IDs and page numbers - const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => { - if (!displayDocument) return []; - return pageIds.map(id => { - const page = displayDocument.pages.find(p => p.id === id); - return page?.pageNumber || 0; - }).filter(num => num > 0); - }, [displayDocument]); - - const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => { - if (!displayDocument) return []; - return pageNumbers.map(num => { - const page = displayDocument.pages.find(p => p.pageNumber === num); - return page?.id || ''; - }).filter(id => id !== ''); - }, [displayDocument]); - - // Select all pages by default when document initially loads - const hasInitializedSelection = useRef(false); - useEffect(() => { - if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) { - const allPageIds = displayDocument.pages.map(p => p.id); - setSelectedPageIds(allPageIds); - setSelectionMode(true); - hasInitializedSelection.current = true; - } - }, [displayDocument, setSelectedPageIds, setSelectionMode]); - - // DOM-first command handlers - const handleRotatePages = useCallback((pageIds: string[], rotation: number) => { - const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); - executeCommandWithTracking(bulkRotateCommand); - }, [executeCommandWithTracking]); - - // Command factory functions for PageThumbnail - const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({ - execute: () => { - const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); - executeCommandWithTracking(bulkRotateCommand); - } - }), [executeCommandWithTracking]); - - const createDeleteCommand = useCallback((pageIds: string[]) => ({ - execute: () => { - if (!displayDocument) return; - - const pagesToDelete = pageIds.map(pageId => { - - const page = displayDocument.pages.find(p => p.id === pageId); - return page?.pageNumber || 0; - }).filter(num => num > 0); - - if (pagesToDelete.length > 0) { - const deleteCommand = new DeletePagesCommand( - pagesToDelete, - () => displayDocument, - setEditedDocument, - (pageNumbers: number[]) => { - const pageIds = getPageIdsFromNumbers(pageNumbers); - setSelectedPageIds(pageIds); - }, - () => splitPositions, - setSplitPositions, - () => getPageNumbersFromIds(selectedPageIds), - closePdf - ); - executeCommandWithTracking(deleteCommand); - } - } - }), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]); - - const createSplitCommand = useCallback((position: number) => ({ - execute: () => { - const splitCommand = new SplitCommand( - position, - () => splitPositions, - setSplitPositions - ); - executeCommandWithTracking(splitCommand); - } -}), [splitPositions, executeCommandWithTracking]); - - // Command executor for PageThumbnail - const executeCommand = useCallback((command: any) => { - if (command && typeof command.execute === 'function') { - command.execute(); - } - }, []); - - - const handleUndo = useCallback(() => { - undoManagerRef.current.undo(); - }, []); - - const handleRedo = useCallback(() => { - undoManagerRef.current.redo(); - }, []); - - const handleRotate = useCallback((direction: 'left' | 'right') => { - if (!displayDocument || selectedPageIds.length === 0) return; - const rotation = direction === 'left' ? -90 : 90; - - handleRotatePages(selectedPageIds, rotation); - }, [displayDocument, selectedPageIds, handleRotatePages]); - - const handleDelete = useCallback(() => { - if (!displayDocument || selectedPageIds.length === 0) return; - - // Convert selected page IDs to page numbers for the command - const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); - - const deleteCommand = new DeletePagesCommand( - selectedPageNumbers, - () => displayDocument, - setEditedDocument, - (pageNumbers: number[]) => { - const pageIds = getPageIdsFromNumbers(pageNumbers); - setSelectedPageIds(pageIds); - }, - () => splitPositions, - setSplitPositions, - () => selectedPageNumbers, - closePdf - ); - executeCommandWithTracking(deleteCommand); - }, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]); - - const handleDeletePage = useCallback((pageNumber: number) => { - if (!displayDocument) return; - - const deleteCommand = new DeletePagesCommand( - [pageNumber], - () => displayDocument, - setEditedDocument, - (pageNumbers: number[]) => { - const pageIds = getPageIdsFromNumbers(pageNumbers); - setSelectedPageIds(pageIds); - }, - () => splitPositions, - setSplitPositions, - () => getPageNumbersFromIds(selectedPageIds), - closePdf - ); - executeCommandWithTracking(deleteCommand); - }, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]); - - const handleSplit = useCallback(() => { - if (!displayDocument || selectedPageIds.length === 0) return; - - // Convert selected page IDs to page numbers, then to split positions (0-based indices) - const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); - const selectedPositions: number[] = []; - selectedPageNumbers.forEach(pageNum => { - const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum); - if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { - // Only allow splits before the last page - selectedPositions.push(pageIndex); - } - }); - - if (selectedPositions.length === 0) return; - - // Smart toggle logic: follow the majority, default to adding splits if equal - const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; - const noSplitsCount = selectedPositions.length - existingSplitsCount; - - // Remove splits only if majority already have splits - // If equal (50/50), default to adding splits - const shouldRemoveSplits = existingSplitsCount > noSplitsCount; - - - const newSplitPositions = new Set(splitPositions); - - if (shouldRemoveSplits) { - // Remove splits from all selected positions - selectedPositions.forEach(pos => newSplitPositions.delete(pos)); - } else { - // Add splits to all selected positions - selectedPositions.forEach(pos => newSplitPositions.add(pos)); - } - - // Create a custom command that sets the final state directly - const smartSplitCommand = { - execute: () => setSplitPositions(newSplitPositions), - undo: () => setSplitPositions(splitPositions), - description: shouldRemoveSplits - ? `Remove ${selectedPositions.length} split(s)` - : `Add ${selectedPositions.length - existingSplitsCount} split(s)` - }; - - executeCommandWithTracking(smartSplitCommand); - }, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]); - - const handleSplitAll = useCallback(() => { - if (!displayDocument || selectedPageIds.length === 0) return; - - // Convert selected page IDs to page numbers, then to split positions (0-based indices) - const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); - const selectedPositions: number[] = []; - selectedPageNumbers.forEach(pageNum => { - const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum); - if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { - // Only allow splits before the last page - selectedPositions.push(pageIndex); - } - }); - - if (selectedPositions.length === 0) return; - - // Smart toggle logic: follow the majority, default to adding splits if equal - const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; - const noSplitsCount = selectedPositions.length - existingSplitsCount; - - // Remove splits only if majority already have splits - // If equal (50/50), default to adding splits - const shouldRemoveSplits = existingSplitsCount > noSplitsCount; - - const newSplitPositions = new Set(splitPositions); - - if (shouldRemoveSplits) { - // Remove splits from all selected positions - selectedPositions.forEach(pos => newSplitPositions.delete(pos)); - } else { - // Add splits to all selected positions - selectedPositions.forEach(pos => newSplitPositions.add(pos)); - } - - // Create a custom command that sets the final state directly - const smartSplitCommand = { - execute: () => setSplitPositions(newSplitPositions), - undo: () => setSplitPositions(splitPositions), - description: shouldRemoveSplits - ? `Remove ${selectedPositions.length} split(s)` - : `Add ${selectedPositions.length - existingSplitsCount} split(s)` - }; - - executeCommandWithTracking(smartSplitCommand); - }, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]); - - const handlePageBreak = useCallback(() => { - if (!displayDocument || selectedPageIds.length === 0) return; - - // Convert selected page IDs to page numbers for the command - const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); - - const pageBreakCommand = new PageBreakCommand( - selectedPageNumbers, - () => displayDocument, - setEditedDocument - ); - executeCommandWithTracking(pageBreakCommand); - }, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]); - - const handlePageBreakAll = useCallback(() => { - if (!displayDocument || selectedPageIds.length === 0) return; - - // Convert selected page IDs to page numbers for the command - const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); - - const pageBreakCommand = new PageBreakCommand( - selectedPageNumbers, - () => displayDocument, - setEditedDocument - ); - executeCommandWithTracking(pageBreakCommand); - }, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]); - - const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => { - if (!displayDocument || files.length === 0) return; - - try { - const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage); - if (!targetPage) return; - - await actions.addFiles(files, { insertAfterPageId: targetPage.id }); - } catch (error) { - console.error('Failed to insert files:', error); - } - }, [displayDocument, actions]); - - const handleSelectAll = useCallback(() => { - if (!displayDocument) return; - const allPageIds = displayDocument.pages.map(p => p.id); - toggleSelectAll(allPageIds); - }, [displayDocument, toggleSelectAll]); - - const handleDeselectAll = useCallback(() => { - setSelectedPageIds([]); - }, [setSelectedPageIds]); - - const handleSetSelectedPages = useCallback((pageNumbers: number[]) => { - const pageIds = getPageIdsFromNumbers(pageNumbers); - setSelectedPageIds(pageIds); - }, [getPageIdsFromNumbers, setSelectedPageIds]); - - const updatePagesFromCSV = useCallback((override?: string) => { - if (totalPages === 0) return; - const normalized = parseSelection(override ?? csvInput, totalPages); - handleSetSelectedPages(normalized); - }, [csvInput, totalPages, handleSetSelectedPages]); - - const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => { - if (!displayDocument) return; - - // Convert selectedPageIds to page numbers for the reorder command - const selectedPages = selectedPageIds ? getPageNumbersFromIds(selectedPageIds) : undefined; - - const reorderCommand = new ReorderPagesCommand( - sourcePageNumber, - targetIndex, - selectedPages, - () => displayDocument, - setEditedDocument - ); - executeCommandWithTracking(reorderCommand); - }, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]); - - // Helper function to collect source files for multi-file export - const getSourceFiles = useCallback((): Map | null => { - const sourceFiles = new Map(); - - // Always include original files - activeFileIds.forEach(fileId => { - const file = selectors.getFile(fileId); - if (file) { - sourceFiles.set(fileId, file); - } - }); - - // Use multi-file export if we have multiple original files - const hasInsertedFiles = false; - const hasMultipleOriginalFiles = activeFileIds.length > 1; - - if (!hasInsertedFiles && !hasMultipleOriginalFiles) { - return null; // Use single-file export method - } - - return sourceFiles.size > 0 ? sourceFiles : null; - }, [activeFileIds, selectors]); - - // Helper function to generate proper filename for exports - const getExportFilename = useCallback((): string => { - if (activeFileIds.length <= 1) { - // Single file - use original name - return displayDocument?.name || 'document.pdf'; - } - - // Multiple files - use first file name with " (merged)" suffix - const firstFile = selectors.getFile(activeFileIds[0]); - if (firstFile) { - const baseName = firstFile.name.replace(/\.pdf$/i, ''); - return `${baseName} (merged).pdf`; - } - - return 'merged-document.pdf'; - }, [activeFileIds, selectors, displayDocument]); - - const onExportSelected = useCallback(async () => { - if (!displayDocument || selectedPageIds.length === 0) return; - - setExportLoading(true); - try { - // Step 1: Apply DOM changes to document state first - const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - mergedPdfDocument || displayDocument, // Original order - displayDocument, // Current display order (includes reordering) - splitPositions // Position-based splits - ); - - // For selected pages export, we work with the first document (or single document) - const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments; - - // Step 2: Use the already selected page IDs - // Filter to only include IDs that exist in the document with DOM state - const validSelectedPageIds = selectedPageIds.filter(pageId => - documentWithDOMState.pages.some(p => p.id === pageId) - ); - - // Step 3: Export with pdfExportService - - const sourceFiles = getSourceFiles(); - const exportFilename = getExportFilename(); - const result = sourceFiles - ? await pdfExportService.exportPDFMultiFile( - documentWithDOMState, - sourceFiles, - validSelectedPageIds, - { selectedOnly: true, filename: exportFilename } - ) - : await pdfExportService.exportPDF( - documentWithDOMState, - validSelectedPageIds, - { selectedOnly: true, filename: exportFilename } - ); - - // Step 4: Download the result - pdfExportService.downloadFile(result.blob, result.filename); - setHasUnsavedChanges(false); // Clear unsaved changes after successful export - - setExportLoading(false); - } catch (error) { - console.error('Export failed:', error); - setExportLoading(false); - } - }, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]); - - const onExportAll = useCallback(async () => { - if (!displayDocument) return; - - setExportLoading(true); - try { - // Step 1: Apply DOM changes to document state first - const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - mergedPdfDocument || displayDocument, - displayDocument, - splitPositions - ); - - // Step 2: Export to files - const sourceFiles = getSourceFiles(); - const exportFilename = getExportFilename(); - const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename); - - // Step 3: Download - if (files.length > 1) { - // Multiple files - create ZIP - const JSZip = await import('jszip'); - const zip = new JSZip.default(); - - files.forEach((file) => { - zip.file(file.name, file); - }); - - const zipBlob = await zip.generateAsync({ type: 'blob' }); - const exportFilename = getExportFilename(); - const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip'); - - pdfExportService.downloadFile(zipBlob, zipFilename); - } else { - // Single file - download directly - const file = files[0]; - pdfExportService.downloadFile(file, file.name); - } - - setHasUnsavedChanges(false); - setExportLoading(false); - } catch (error) { - console.error('Export failed:', error); - setExportLoading(false); - } - }, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]); - - // Apply DOM changes to document state using dedicated service - const applyChanges = useCallback(async () => { - if (!displayDocument) return; - - setExportLoading(true); - try { - // Step 1: Apply DOM changes to document state first - const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - mergedPdfDocument || displayDocument, - displayDocument, - splitPositions - ); - - // Step 2: Export to files - const sourceFiles = getSourceFiles(); - const exportFilename = getExportFilename(); - const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename); - - // Step 3: Create StirlingFiles and stubs for version history - const parentStub = selectors.getStirlingFileStub(activeFileIds[0]); - if (!parentStub) throw new Error('Parent stub not found'); - - const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool'); - - // Step 4: Consume files (replace in context) - await actions.consumeFiles(activeFileIds, stirlingFiles, stubs); - - setHasUnsavedChanges(false); - setExportLoading(false); - } catch (error) { - console.error('Apply changes failed:', error); - setExportLoading(false); - } - }, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]); - - - const closePdf = useCallback(() => { - actions.clearAllFiles(); - - undoManagerRef.current.clear(); - setSelectedPageIds([]); - setSelectionMode(false); - }, [actions]); + const activeFileIds = selectedFileIds; usePageEditorRightRailButtons({ totalPages, @@ -638,6 +247,7 @@ const PageEditor = ({ handleDeselectAll, handleDelete, onExportSelected, + onSaveChanges: applyChanges, exportLoading, activeFileCount: activeFileIds.length, closePdf, @@ -692,14 +302,59 @@ const PageEditor = ({ selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf ]); + useWheelZoom({ + ref: containerRef, + onZoomIn: zoomIn, + onZoomOut: zoomOut, + enabled: !!displayDocument, + }); + + // Handle keyboard zoom shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isContainerHovered) return; + + // Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed + if (event.ctrlKey || event.metaKey) { + if (event.key === '=' || event.key === '+') { + // Ctrl+= or Ctrl++ for zoom in + event.preventDefault(); + zoomIn(); + } else if (event.key === '-' || event.key === '_') { + // Ctrl+- for zoom out + event.preventDefault(); + zoomOut(); + } else if (event.key === '0') { + // Ctrl+0 for reset zoom + event.preventDefault(); + setZoomLevel(1.0); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isContainerHovered, zoomIn, zoomOut]); + // Display all pages - use edited or original document const displayedPages = displayDocument?.pages || []; - return ( - - + // Track color assignments by insertion order (files keep their color) + const fileColorIndexMap = useFileColorMap(orderedFileIds); - {!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && ( + return ( + setIsContainerHovered(true)} + onMouseLeave={() => setIsContainerHovered(false)} + > + + + {!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
📄 @@ -709,7 +364,7 @@ const PageEditor = ({
)} - {!mergedPdfDocument && globalProcessing && ( + {!initialDocument && globalProcessing && ( @@ -717,61 +372,74 @@ const PageEditor = ({ )} {displayDocument && ( - + - - {/* Split Lines Overlay */} -
+ {/* Split Lines Overlay */} +
{(() => { - // Calculate remToPx once outside the map to avoid layout thrashing - const containerWidth = containerDimensions.width; - const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); - const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; - const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; - const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + const refsMap = gridItemRefsRef.current?.current; + const containerEl = gridContainerRef.current; + if (!refsMap || !containerEl) { + return null; + } + + const containerRect = containerEl.getBoundingClientRect(); return Array.from(splitPositions).map((position) => { + const currentPage = displayedPages[position]; + if (!currentPage) { + return null; + } - // Calculate items per row using DragDropGrid's logic - const availableWidth = containerWidth - ITEM_GAP; // Account for first gap - const itemWithGap = ITEM_WIDTH + ITEM_GAP; - const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap)); + const currentEl = refsMap.get(currentPage.id); + if (!currentEl) { + return null; + } - // Calculate position within the grid (same as DragDropGrid) - const row = Math.floor(position / itemsPerRow); - const col = position % itemsPerRow; + const currentRect = currentEl.getBoundingClientRect(); + const nextPage = displayedPages[position + 1]; + let lineLeft; - // Position split line between pages (after the current page) - // Calculate grid centering offset (same as DragDropGrid) - const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP; - const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2); + if (nextPage) { + const nextEl = refsMap.get(nextPage.id); + if (nextEl) { + const nextRect = nextEl.getBoundingClientRect(); + const sameRow = Math.abs(nextRect.top - currentRect.top) < currentRect.height / 2; + if (sameRow) { + lineLeft = (currentRect.right + nextRect.left) / 2; + } else { + lineLeft = currentRect.right + itemGapPx / 2; + } + } else { + lineLeft = currentRect.right + itemGapPx / 2; + } + } else { + lineLeft = currentRect.right + itemGapPx / 2; + } - const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2); - const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height) - - return ( -
- ); + return ( +
+ ); }); })()}
@@ -779,40 +447,58 @@ const PageEditor = ({ {/* Pages Grid */} ( - {}} - onSetMovingPage={setMovingPage} - onDeletePage={handleDeletePage} - createRotateCommand={createRotateCommand} - createDeleteCommand={createDeleteCommand} - createSplitCommand={createSplitCommand} - pdfDocument={displayDocument} - setPdfDocument={setEditedDocument} - splitPositions={splitPositions} - onInsertFiles={handleInsertFiles} - /> - )} + zoomLevel={zoomLevel} + selectedFileIds={selectedFileIds} + getThumbnailData={(pageId) => { + const page = displayDocument.pages.find(p => p.id === pageId); + if (!page?.thumbnail) return null; + return { + src: page.thumbnail, + rotation: page.rotation || 0 + }; + }} + renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, _getBoxSelection, _activeId, activeDragIds, justMoved, _isOver, dragHandleProps, zoomLevel) => { + gridItemRefsRef.current = refs; + const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0; + const isBoxSelected = boxSelectedIds.includes(page.id); + return ( + {}} + onSetMovingPage={setMovingPage} + onDeletePage={handleDeletePage} + createRotateCommand={createRotateCommand} + createDeleteCommand={createDeleteCommand} + createSplitCommand={createSplitCommand} + pdfDocument={displayDocument} + setPdfDocument={setEditedDocument} + splitPositions={splitPositions} + onInsertFiles={handleInsertFiles} + zoomLevel={zoomLevel} + /> + ); + }} /> - )} @@ -825,6 +511,7 @@ const PageEditor = ({ await onExportAll(); }} /> + ); }; diff --git a/frontend/src/core/components/pageEditor/PageEditorControls.tsx b/frontend/src/core/components/pageEditor/PageEditorControls.tsx index 3e36b06b61..7e16064a94 100644 --- a/frontend/src/core/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/core/components/pageEditor/PageEditorControls.tsx @@ -138,12 +138,12 @@ const PageEditorControls = ({ {/* Undo/Redo */} - + - + @@ -156,7 +156,7 @@ const PageEditorControls = ({ onClick={() => onRotate('left')} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: 'var(--mantine-color-dimmed)' }} + style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg" > @@ -168,7 +168,7 @@ const PageEditorControls = ({ onClick={() => onRotate('right')} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: 'var(--mantine-color-dimmed)' }} + style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg" > @@ -180,7 +180,7 @@ const PageEditorControls = ({ onClick={onDelete} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: 'var(--mantine-color-dimmed)' }} + style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg" > @@ -192,7 +192,7 @@ const PageEditorControls = ({ onClick={onSplit} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: 'var(--mantine-color-dimmed)' }} + style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg" > @@ -204,7 +204,7 @@ const PageEditorControls = ({ onClick={onPageBreak} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: 'var(--mantine-color-dimmed)' }} + style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg" > diff --git a/frontend/src/core/components/pageEditor/PageThumbnail.tsx b/frontend/src/core/components/pageEditor/PageThumbnail.tsx index 1a33e779b0..2cc7d38fab 100644 --- a/frontend/src/core/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/core/components/pageEditor/PageThumbnail.tsx @@ -8,12 +8,13 @@ import RotateRightIcon from '@mui/icons-material/RotateRight'; import DeleteIcon from '@mui/icons-material/Delete'; import ContentCutIcon from '@mui/icons-material/ContentCut'; import AddIcon from '@mui/icons-material/Add'; -import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { PDFPage, PDFDocument } from '@app/types/pageEditor'; import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; +import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors'; import styles from '@app/components/pageEditor/PageEditor.module.css'; import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu'; +import { StirlingFileStub } from '@app/types/fileContext'; import { PrivateContent } from '@app/components/shared/PrivateContent'; @@ -22,11 +23,17 @@ interface PageThumbnailProps { index: number; totalPages: number; originalFile?: File; + fileColorIndex: number; selectedPageIds: string[]; selectionMode: boolean; movingPage: number | null; isAnimating: boolean; + isBoxSelected?: boolean; + clearBoxSelection?: () => void; + activeDragIds: string[]; + justMoved?: boolean; pageRefs: React.MutableRefObject>; + dragHandleProps?: any; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; onTogglePage: (pageId: string) => void; onAnimateReorder: () => void; @@ -40,19 +47,25 @@ interface PageThumbnailProps { pdfDocument: PDFDocument; setPdfDocument: (doc: PDFDocument) => void; splitPositions: Set; - onInsertFiles?: (files: File[], insertAfterPage: number) => void; + onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void; + zoomLevel?: number; } const PageThumbnail: React.FC = ({ page, - index, + index: _index, totalPages, originalFile, + fileColorIndex, selectedPageIds, selectionMode, movingPage, isAnimating, + isBoxSelected = false, + clearBoxSelection, + activeDragIds, pageRefs, + dragHandleProps, onReorderPages, onTogglePage, onExecuteCommand, @@ -64,17 +77,25 @@ const PageThumbnail: React.FC = ({ pdfDocument, splitPositions, onInsertFiles, + zoomLevel = 1.0, + justMoved = false, }: PageThumbnailProps) => { - const [isDragging, setIsDragging] = useState(false); + const pageIndex = page.pageNumber - 1; + const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); const [isHovered, setIsHovered] = useState(false); const isMobile = useIsMobile(); - const dragElementRef = useRef(null); + const lastClickTimeRef = useRef(0); + const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); - const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); + const elementRef = useRef(null); + const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration(); const { openFilesModal } = useFilesModalContext(); + // Check if this page is currently being dragged + const isDragging = activeDragIds.includes(page.id); + // Calculate document aspect ratio from first non-blank page const getDocumentAspectRatio = useCallback(() => { // Find first non-blank page with a thumbnail to get aspect ratio @@ -131,63 +152,22 @@ const PageThumbnail: React.FC = ({ }; }, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]); - const pageElementRef = useCallback((element: HTMLDivElement | null) => { + // Merge refs - combine our ref tracking with dnd-kit's ref + const mergedRef = useCallback((element: HTMLDivElement | null) => { + // Track in our refs map + elementRef.current = element; if (element) { pageRefs.current.set(page.id, element); - dragElementRef.current = element; - - const dragCleanup = draggable({ - element, - getInitialData: () => ({ - pageNumber: page.pageNumber, - pageId: page.id, - selectedPageIds: [page.id] - }), - onDragStart: () => { - setIsDragging(true); - }, - onDrop: ({ location }) => { - setIsDragging(false); - - if (location.current.dropTargets.length === 0) { - return; - } - - const dropTarget = location.current.dropTargets[0]; - const targetData = dropTarget.data; - - if (targetData.type === 'page') { - const targetPageNumber = targetData.pageNumber as number; - const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); - if (targetIndex !== -1) { - onReorderPages(page.pageNumber, targetIndex, undefined); - } - } - } - }); - - element.style.cursor = 'grab'; - - const dropCleanup = dropTargetForElements({ - element, - getData: () => ({ - type: 'page', - pageNumber: page.pageNumber - }), - onDrop: (_) => {} - }); - - (element as any).__dragCleanup = () => { - dragCleanup(); - dropCleanup(); - }; } else { pageRefs.current.delete(page.id); - if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) { - (dragElementRef.current as any).__dragCleanup(); - } } - }, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]); + + // Call dnd-kit's ref if provided + if (dragHandleProps?.ref) { + dragHandleProps.ref(element); + } + }, [page.id, pageRefs, dragHandleProps]); + // DOM command handlers const handleRotateLeft = useCallback((e: React.MouseEvent) => { @@ -216,13 +196,13 @@ const PageThumbnail: React.FC = ({ e.stopPropagation(); // Create a command to toggle split at this position - const command = createSplitCommand(index); + const command = createSplitCommand(pageIndex); onExecuteCommand(command); - const hasSplit = splitPositions.has(index); + const hasSplit = splitPositions.has(pageIndex); const action = hasSplit ? 'removed' : 'added'; - onSetStatus(`Split marker ${action} after position ${index + 1}`); - }, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]); + onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`); + }, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]); const handleInsertFileAfter = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -231,9 +211,9 @@ const PageThumbnail: React.FC = ({ // Open file manager modal with custom handler for page insertion openFilesModal({ insertAfterPage: page.pageNumber, - customHandler: (files: File[], insertAfterPage?: number) => { + customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => { if (insertAfterPage !== undefined) { - onInsertFiles(files, insertAfterPage); + onInsertFiles(files, insertAfterPage, isFromStorage); } } }); @@ -263,14 +243,28 @@ const PageThumbnail: React.FC = ({ const deltaY = Math.abs(e.clientY - mouseStartPos.y); const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - // If mouse moved less than 5 pixels, consider it a click (not a drag) - if (distance < 5 && !isDragging) { - onTogglePage(page.id); + // If mouse moved less than 2 pixels, consider it a click (not a drag) + if (distance < 2 && !isDragging) { + // Prevent rapid double-clicks from causing issues (debounce with 100ms threshold) + const now = Date.now(); + if (now - lastClickTimeRef.current > 100) { + lastClickTimeRef.current = now; + + // Clear box selection when clicking on a non-selected page + if (!isBoxSelected && clearBoxSelection) { + clearBoxSelection(); + } + + // Don't toggle page selection if it's box-selected (just keep the box selection) + if (!isBoxSelected) { + onTogglePage(page.id); + } + } } setIsMouseDown(false); setMouseStartPos(null); - }, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]); + }, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]); const handleMouseLeave = useCallback(() => { setIsMouseDown(false); @@ -278,6 +272,11 @@ const PageThumbnail: React.FC = ({ setIsHovered(false); }, []); + const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3); + + // Spread dragHandleProps but use our merged ref + const { ref: _, ...restDragProps } = dragHandleProps || {}; + // Build hover menu actions const hoverActions = useMemo(() => [ { @@ -286,14 +285,14 @@ const PageThumbnail: React.FC = ({ label: 'Move Left', onClick: (e) => { e.stopPropagation(); - if (index > 0 && !movingPage && !isAnimating) { + if (pageIndex > 0 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); - onReorderPages(page.pageNumber, index - 1); + onReorderPages(page.pageNumber, pageIndex - 1); setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} left`); } }, - disabled: index === 0 + disabled: pageIndex === 0 }, { id: 'move-right', @@ -301,14 +300,17 @@ const PageThumbnail: React.FC = ({ label: 'Move Right', onClick: (e) => { e.stopPropagation(); - if (index < totalPages - 1 && !movingPage && !isAnimating) { + if (pageIndex < totalPages - 1 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); - onReorderPages(page.pageNumber, index + 1); + // ReorderPagesCommand expects target index relative to the original array. + // When moving toward the right (higher index), provide desiredIndex + 1 + // so the command's internal adjustment (targetIndex - 1) lands correctly. + onReorderPages(page.pageNumber, pageIndex + 2); setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} right`); } }, - disabled: index === totalPages - 1 + disabled: pageIndex === totalPages - 1 }, { id: 'rotate-left', @@ -334,7 +336,7 @@ const PageThumbnail: React.FC = ({ icon: , label: 'Split After', onClick: handleSplit, - hidden: index >= totalPages - 1, + hidden: pageIndex >= totalPages - 1, }, { id: 'insert', @@ -342,11 +344,12 @@ const PageThumbnail: React.FC = ({ label: 'Insert File After', onClick: handleInsertFileAfter, } - ], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]); + ], [pageIndex, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]); return (
= ({ !rounded-lg ${selectionMode ? 'cursor-pointer' : 'cursor-grab'} select-none - w-[20rem] - h-[20rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative - ${selectionMode - ? 'bg-white hover:bg-gray-50' - : 'bg-white hover:bg-gray-50'} ${isDragging ? 'opacity-50 scale-95' : ''} ${movingPage === page.pageNumber ? 'page-moving' : ''} + ${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''} `} style={{ - transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' + width: `calc(20rem * ${zoomLevel})`, + height: `calc(20rem * ${zoomLevel})`, + transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out', + zIndex: isHovered ? 50 : 1, + ...(isBoxSelected && { + boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)', + }), }} - draggable={false} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onMouseEnter={() => setIsHovered(true)} @@ -414,12 +418,13 @@ const PageThumbnail: React.FC = ({
= ({ backgroundColor: 'white', border: '1px solid #e9ecef', borderRadius: 2 - }}>
+ }} />
) : thumbnailUrl ? ( {`Page = ({ position: 'absolute', top: 5, left: 5, - background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)', + background: 'rgba(162, 201, 255, 0.8)', padding: '6px 8px', borderRadius: 8, zIndex: 2, diff --git a/frontend/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/src/core/components/pageEditor/commands/pageCommands.ts index b39fe663d8..065a18ee81 100644 --- a/frontend/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/core/components/pageEditor/commands/pageCommands.ts @@ -1,5 +1,5 @@ import { FileId } from '@app/types/file'; -import { PDFDocument, PDFPage } from '@app/types/pageEditor'; +import { PDFDocument, PDFPage, PageBreakSettings } from '@app/types/pageEditor'; // V1-style DOM-first command system (replaces the old React state commands) export abstract class DOMCommand { @@ -67,7 +67,7 @@ export class DeletePagesCommand extends DOMCommand { private pagesToDelete: number[], private getCurrentDocument: () => PDFDocument | null, private setDocument: (doc: PDFDocument) => void, - private setSelectedPages: (pages: number[]) => void, + private setSelectedPageIds: (pageIds: string[]) => void, private getSplitPositions: () => Set, private setSplitPositions: (positions: Set) => void, private getSelectedPages: () => number[], @@ -99,6 +99,13 @@ export class DeletePagesCommand extends DOMCommand { this.hasExecuted = true; } + const selectedPageNumbersBefore = this.getSelectedPages(); + const selectedIdSet = new Set( + selectedPageNumbersBefore + .map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id) + .filter((id): id is string => Boolean(id)) + ); + // Filter out deleted pages by ID (stable across undo/redo) const remainingPages = currentDoc.pages.filter(page => !this.pageIdsToDelete.includes(page.id) @@ -106,7 +113,7 @@ export class DeletePagesCommand extends DOMCommand { if (remainingPages.length === 0) { // If all pages would be deleted, clear selection/splits and close PDF - this.setSelectedPages([]); + this.setSelectedPageIds([]); this.setSplitPositions(new Set()); this.onAllPagesDeleted?.(); return; @@ -135,7 +142,12 @@ export class DeletePagesCommand extends DOMCommand { // Apply changes this.setDocument(updatedDocument); - this.setSelectedPages([]); + + const remainingSelectedPageIds = remainingPages + .filter((page) => selectedIdSet.has(page.id)) + .map((page) => page.id); + this.setSelectedPageIds(remainingSelectedPageIds); + this.setSplitPositions(newPositions); } @@ -145,7 +157,12 @@ export class DeletePagesCommand extends DOMCommand { // Simply restore the complete original document state this.setDocument(this.originalDocument); this.setSplitPositions(this.originalSplitPositions); - this.setSelectedPages(this.originalSelectedPages); + const restoredIds = this.originalSelectedPages + .map((pageNum) => + this.originalDocument!.pages.find((page) => page.pageNumber === pageNum)?.id || "" + ) + .filter((id) => id !== ""); + this.setSelectedPageIds(restoredIds); } get description(): string { @@ -161,7 +178,8 @@ export class ReorderPagesCommand extends DOMCommand { private targetIndex: number, private selectedPages: number[] | undefined, private getCurrentDocument: () => PDFDocument | null, - private setDocument: (doc: PDFDocument) => void + private setDocument: (doc: PDFDocument) => void, + private onReorderComplete?: (newPages: PDFPage[]) => void ) { super(); } @@ -196,7 +214,13 @@ export class ReorderPagesCommand extends DOMCommand { } else { // Single page reorder const [movedPage] = newPages.splice(sourceIndex, 1); - newPages.splice(this.targetIndex, 0, movedPage); + + // Adjust target index if moving forward (after removal, indices shift) + const adjustedTargetIndex = sourceIndex < this.targetIndex + ? this.targetIndex - 1 + : this.targetIndex; + + newPages.splice(adjustedTargetIndex, 0, movedPage); newPages.forEach((page, index) => { page.pageNumber = index + 1; @@ -210,6 +234,11 @@ export class ReorderPagesCommand extends DOMCommand { }; this.setDocument(reorderedDocument); + + // Notify that reordering is complete + if (this.onReorderComplete) { + this.onReorderComplete(newPages); + } } undo(): void { @@ -408,6 +437,8 @@ export class SplitAllCommand extends DOMCommand { } } +// PageBreakSettings, PageSize, and PageOrientation are now imported from pageEditor.ts + export class PageBreakCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; @@ -415,7 +446,8 @@ export class PageBreakCommand extends DOMCommand { constructor( private selectedPageNumbers: number[], private getCurrentDocument: () => PDFDocument | null, - private setDocument: (doc: PDFDocument) => void + private setDocument: (doc: PDFDocument) => void, + private settings?: PageBreakSettings ) { super(); } @@ -450,7 +482,8 @@ export class PageBreakCommand extends DOMCommand { rotation: 0, selected: false, splitAfter: false, - isBlankPage: true // Custom flag for blank pages + isBlankPage: true, // Custom flag for blank pages + pageBreakSettings: this.settings // Store settings for export }; newPages.push(blankPage); this.insertedPages.push(blankPage); @@ -883,6 +916,10 @@ export class UndoManager { return this.redoStack.length > 0; } + hasHistory(): boolean { + return this.undoStack.length > 0; + } + clear(): void { this.undoStack = []; this.redoStack = []; diff --git a/frontend/src/core/components/pageEditor/fileColors.ts b/frontend/src/core/components/pageEditor/fileColors.ts new file mode 100644 index 0000000000..fa9b0c35fd --- /dev/null +++ b/frontend/src/core/components/pageEditor/fileColors.ts @@ -0,0 +1,61 @@ +/** + * File color palette for page editor + * Each file gets a distinct color for visual organization + * Colors are applied at 0.3 opacity for subtle highlighting + * Maximum 20 files supported in page editor + */ + +export const FILE_COLORS = [ + // Subtle colors (1-6) - fit well with UI theme + 'rgb(59, 130, 246)', // Blue + 'rgb(16, 185, 129)', // Green + 'rgb(139, 92, 246)', // Purple + 'rgb(6, 182, 212)', // Cyan + 'rgb(20, 184, 166)', // Teal + 'rgb(99, 102, 241)', // Indigo + + // Mid-range colors (7-12) - more distinct + 'rgb(244, 114, 182)', // Pink + 'rgb(251, 146, 60)', // Orange + 'rgb(234, 179, 8)', // Yellow + 'rgb(132, 204, 22)', // Lime + 'rgb(248, 113, 113)', // Red + 'rgb(168, 85, 247)', // Violet + + // Vibrant colors (13-20) - maximum distinction + 'rgb(236, 72, 153)', // Fuchsia + 'rgb(245, 158, 11)', // Amber + 'rgb(34, 197, 94)', // Emerald + 'rgb(14, 165, 233)', // Sky + 'rgb(239, 68, 68)', // Rose + 'rgb(168, 162, 158)', // Stone + 'rgb(251, 191, 36)', // Gold + 'rgb(192, 132, 252)', // Light Purple +] as const; + +export const MAX_PAGE_EDITOR_FILES = 20; + +/** + * Get color for a file by its index + * @param index - Zero-based file index + * @returns RGB color string + */ +export function getFileColor(index: number): string { + if (index < 0 || index >= FILE_COLORS.length) { + console.warn(`File index ${index} out of range, using default color`); + return FILE_COLORS[0]; + } + return FILE_COLORS[index]; +} + +/** + * Get color with specified opacity + * @param index - Zero-based file index + * @param opacity - Opacity value (0-1), defaults to 0.3 + * @returns RGBA color string + */ +export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string { + const rgb = getFileColor(index); + // Convert rgb(r, g, b) to rgba(r, g, b, a) + return rgb.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`); +} diff --git a/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts b/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts new file mode 100644 index 0000000000..10c0cacf9c --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { FileId } from "@app/types/file"; +import { PDFDocument, PDFPage } from "@app/types/pageEditor"; + +interface UseEditedDocumentStateParams { + initialDocument: PDFDocument | null; + mergedPdfDocument: PDFDocument | null; + reorderedPages: PDFPage[] | null; + clearReorderedPages: () => void; + fileOrder: FileId[]; + updateCurrentPages: (pages: PDFPage[] | null) => void; +} + +export const useEditedDocumentState = ({ + initialDocument, + mergedPdfDocument, + reorderedPages, + clearReorderedPages, + fileOrder, + updateCurrentPages, +}: UseEditedDocumentStateParams) => { + const [editedDocument, setEditedDocument] = useState(null); + const editedDocumentRef = useRef(null); + const pagePositionCacheRef = useRef>(new Map()); + const pageNeighborCacheRef = useRef>(new Map()); + const lastSyncedSignatureRef = useRef(null); + + // Clone the initial document once so we can safely mutate working state + useEffect(() => { + if (!initialDocument || editedDocument) return; + + setEditedDocument({ + ...initialDocument, + pages: initialDocument.pages.map((page) => ({ ...page })), + }); + }, [initialDocument, editedDocument]); + + // Apply reorders triggered elsewhere in the editor + useEffect(() => { + if (!reorderedPages || !editedDocument) return; + + setEditedDocument({ + ...editedDocument, + pages: reorderedPages, + }); + clearReorderedPages(); + }, [reorderedPages, editedDocument, clearReorderedPages]); + + // Keep ref synced so effects can read latest without re-running + useEffect(() => { + editedDocumentRef.current = editedDocument; + }, [editedDocument]); + + // Cache page positions to help future insertions preserve intent + useEffect(() => { + if (!editedDocument) return; + + const positionCache = pagePositionCacheRef.current; + const neighborCache = pageNeighborCacheRef.current; + const pages = editedDocument.pages; + + pages.forEach((page, index) => { + positionCache.set(page.id, index); + neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null); + }); + }, [editedDocument]); + + const fileOrderKey = useMemo(() => fileOrder.join(","), [fileOrder]); + const mergedDocSignature = useMemo(() => { + if (!mergedPdfDocument?.pages) return ""; + return mergedPdfDocument.pages.map((page) => page.id).join(","); + }, [mergedPdfDocument]); + + useEffect(() => { + if (!mergedPdfDocument) { + lastSyncedSignatureRef.current = null; + } + }, [mergedPdfDocument]); + + // Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing) + useEffect(() => { + const currentEditedDocument = editedDocumentRef.current; + if (!mergedPdfDocument || !currentEditedDocument) return; + + const signatureChanged = + mergedDocSignature !== lastSyncedSignatureRef.current; + const metadataChanged = + currentEditedDocument.id !== mergedPdfDocument.id || + currentEditedDocument.file !== mergedPdfDocument.file || + currentEditedDocument.name !== mergedPdfDocument.name; + + if (!signatureChanged && !metadataChanged) return; + + setEditedDocument((prev) => { + if (!prev) return prev; + + let pages = prev.pages; + + if (signatureChanged) { + const sourcePages = mergedPdfDocument.pages; + const sourceIds = new Set(sourcePages.map((p) => p.id)); + const prevIds = new Set(prev.pages.map((p) => p.id)); + + const newPages: PDFPage[] = []; + for (const page of sourcePages) { + if (!prevIds.has(page.id)) { + newPages.push(page); + } + } + + const hasAdditions = newPages.length > 0; + const isEphemeralPage = (page: PDFPage) => + Boolean(page.isBlankPage || page.isPlaceholder); + + let hasRemovals = false; + for (const page of prev.pages) { + if (!sourceIds.has(page.id) && !isEphemeralPage(page)) { + hasRemovals = true; + break; + } + } + + if (hasAdditions || hasRemovals) { + pages = [...prev.pages]; + + const placeholderPositions = new Map(); + pages.forEach((page, index) => { + if (page.isPlaceholder && page.originalFileId) { + placeholderPositions.set(page.originalFileId, index); + } + }); + + const nextInsertIndexByFile = new Map(placeholderPositions); + + if (hasRemovals) { + pages = pages.filter( + (page) => sourceIds.has(page.id) || isEphemeralPage(page) + ); + } + + if (hasAdditions) { + const mergedIndexMap = new Map(); + sourcePages.forEach((page, index) => + mergedIndexMap.set(page.id, index) + ); + + const additions = newPages + .map((page) => ({ + page, + cachedIndex: pagePositionCacheRef.current.get(page.id), + mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length, + neighborId: pageNeighborCacheRef.current.get(page.id), + })) + .sort((a, b) => { + const aIndex = a.cachedIndex ?? a.mergedIndex; + const bIndex = b.cachedIndex ?? b.mergedIndex; + if (aIndex !== bIndex) return aIndex - bIndex; + return a.mergedIndex - b.mergedIndex; + }); + + additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => { + if (pages.some((existing) => existing.id === page.id)) { + return; + } + + let insertIndex: number; + const originalFileId = page.originalFileId; + const placeholderIndex = + originalFileId !== undefined + ? nextInsertIndexByFile.get(originalFileId) + : undefined; + + if (originalFileId && placeholderIndex !== undefined) { + insertIndex = Math.min(placeholderIndex, pages.length); + nextInsertIndexByFile.set(originalFileId, insertIndex + 1); + } else if (neighborId === null) { + insertIndex = 0; + } else if (neighborId) { + const neighborIndex = pages.findIndex((p) => p.id === neighborId); + if (neighborIndex !== -1) { + insertIndex = neighborIndex + 1; + } else { + const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length; + insertIndex = Math.min(fallbackIndex, pages.length); + } + } else { + const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length; + insertIndex = Math.min(fallbackIndex, pages.length); + } + + const clonedPage = { ...page }; + pages.splice(insertIndex, 0, clonedPage); + }); + } + + pages = pages.map((page, index) => ({ + ...page, + pageNumber: index + 1, + })); + } + } + + const shouldReplaceBase = metadataChanged || signatureChanged; + const baseDocument = shouldReplaceBase + ? { + ...mergedPdfDocument, + destroy: prev.destroy, + } + : prev; + + if (baseDocument === prev && pages === prev.pages) { + return prev; + } + + return { + ...baseDocument, + pages, + totalPages: pages.length, + }; + }); + + if (signatureChanged) { + lastSyncedSignatureRef.current = mergedDocSignature; + } + }, [mergedPdfDocument, fileOrderKey, mergedDocSignature]); + + const displayDocument = editedDocument || initialDocument; + + const getEditedDocument = useCallback( + () => editedDocumentRef.current, + [] + ); + + useEffect(() => { + updateCurrentPages(displayDocument?.pages ?? null); + }, [displayDocument, updateCurrentPages]); + + return { + editedDocument, + setEditedDocument, + displayDocument, + getEditedDocument, + }; +}; + +export type UseEditedDocumentStateReturn = ReturnType< + typeof useEditedDocumentState +>; diff --git a/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts b/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts new file mode 100644 index 0000000000..45253492f5 --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts @@ -0,0 +1,420 @@ +import { useCallback } from "react"; + +import { + BulkRotateCommand, + DeletePagesCommand, + PageBreakCommand, + ReorderPagesCommand, + SplitCommand, +} from "@app/components/pageEditor/commands/pageCommands"; +import type { + useFileActions, + useFileState, +} from "@app/contexts/FileContext"; +import { PDFDocument, PDFPage } from "@app/types/pageEditor"; +import { FileId } from "@app/types/file"; +import { StirlingFileStub } from "@app/types/fileContext"; + +type FileActions = ReturnType["actions"]; +type FileSelectors = ReturnType["selectors"]; + +interface UsePageEditorCommandsParams { + displayDocument: PDFDocument | null; + getEditedDocument: () => PDFDocument | null; + setEditedDocument: React.Dispatch>; + splitPositions: Set; + setSplitPositions: React.Dispatch>>; + selectedPageIds: string[]; + setSelectedPageIds: (ids: string[]) => void; + getPageNumbersFromIds: (pageIds: string[]) => number[]; + executeCommandWithTracking: (command: any) => void; + updateFileOrderFromPages: (pages: PDFPage[]) => void; + actions: FileActions; + selectors: FileSelectors; + setSelectionMode: (enabled: boolean) => void; + clearUndoHistory: () => void; +} + +export const usePageEditorCommands = ({ + displayDocument, + getEditedDocument, + setEditedDocument, + splitPositions, + setSplitPositions, + selectedPageIds, + setSelectedPageIds, + getPageNumbersFromIds, + executeCommandWithTracking, + updateFileOrderFromPages, + actions, + selectors, + setSelectionMode, + clearUndoHistory, +}: UsePageEditorCommandsParams) => { + const closePdf = useCallback(() => { + actions.clearAllFiles(); + clearUndoHistory(); + setSelectedPageIds([]); + setSelectionMode(false); + }, [actions, clearUndoHistory, setSelectedPageIds, setSelectionMode]); + + const handleRotatePages = useCallback( + (pageIds: string[], rotation: number) => { + const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); + executeCommandWithTracking(bulkRotateCommand); + }, + [executeCommandWithTracking] + ); + + const createRotateCommand = useCallback( + (pageIds: string[], rotation: number) => ({ + execute: () => { + const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); + executeCommandWithTracking(bulkRotateCommand); + }, + }), + [executeCommandWithTracking] + ); + + const createDeleteCommand = useCallback( + (pageIds: string[]) => ({ + execute: () => { + const currentDocument = getEditedDocument(); + if (!currentDocument) return; + + const pagesToDelete = pageIds + .map((pageId) => { + const page = currentDocument.pages.find((p) => p.id === pageId); + return page?.pageNumber || 0; + }) + .filter((num) => num > 0); + + if (pagesToDelete.length > 0) { + const deleteCommand = new DeletePagesCommand( + pagesToDelete, + getEditedDocument, + setEditedDocument, + setSelectedPageIds, + () => splitPositions, + setSplitPositions, + () => getPageNumbersFromIds(selectedPageIds), + () => closePdf() + ); + executeCommandWithTracking(deleteCommand); + } + }, + }), + [ + closePdf, + executeCommandWithTracking, + getEditedDocument, + getPageNumbersFromIds, + selectedPageIds, + setEditedDocument, + setSelectedPageIds, + setSplitPositions, + splitPositions, + ] + ); + + const createSplitCommand = useCallback( + (position: number) => ({ + execute: () => { + const splitCommand = new SplitCommand( + position, + () => splitPositions, + setSplitPositions + ); + executeCommandWithTracking(splitCommand); + }, + }), + [splitPositions, executeCommandWithTracking, setSplitPositions] + ); + + const executeCommand = useCallback((command: any) => { + if (command && typeof command.execute === "function") { + command.execute(); + } + }, []); + + const handleRotate = useCallback( + (direction: "left" | "right") => { + if (!displayDocument || selectedPageIds.length === 0) return; + const rotation = direction === "left" ? -90 : 90; + + handleRotatePages(selectedPageIds, rotation); + }, + [displayDocument, selectedPageIds, handleRotatePages] + ); + + const handleDelete = useCallback(() => { + if (!displayDocument || selectedPageIds.length === 0) return; + + const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); + + const deleteCommand = new DeletePagesCommand( + selectedPageNumbers, + getEditedDocument, + setEditedDocument, + setSelectedPageIds, + () => splitPositions, + setSplitPositions, + () => selectedPageNumbers, + () => closePdf() + ); + executeCommandWithTracking(deleteCommand); + }, [ + closePdf, + displayDocument, + executeCommandWithTracking, + getEditedDocument, + getPageNumbersFromIds, + selectedPageIds, + setEditedDocument, + setSelectedPageIds, + setSplitPositions, + splitPositions, + ]); + + const handleDeletePage = useCallback( + (pageNumber: number) => { + if (!displayDocument) return; + + const deleteCommand = new DeletePagesCommand( + [pageNumber], + getEditedDocument, + setEditedDocument, + setSelectedPageIds, + () => splitPositions, + setSplitPositions, + () => getPageNumbersFromIds(selectedPageIds), + () => closePdf() + ); + executeCommandWithTracking(deleteCommand); + }, + [ + closePdf, + getEditedDocument, + executeCommandWithTracking, + getPageNumbersFromIds, + selectedPageIds, + setEditedDocument, + setSelectedPageIds, + setSplitPositions, + splitPositions, + ] + ); + + const handleSplit = useCallback(() => { + if (!displayDocument || selectedPageIds.length === 0) return; + + const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); + const selectedPositions: number[] = []; + selectedPageNumbers.forEach((pageNum) => { + const pageIndex = displayDocument.pages.findIndex( + (p) => p.pageNumber === pageNum + ); + if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { + selectedPositions.push(pageIndex); + } + }); + + if (selectedPositions.length === 0) return; + + const existingSplitsCount = selectedPositions.filter((pos) => + splitPositions.has(pos) + ).length; + const noSplitsCount = selectedPositions.length - existingSplitsCount; + const shouldRemoveSplits = existingSplitsCount > noSplitsCount; + + const newSplitPositions = new Set(splitPositions); + + if (shouldRemoveSplits) { + selectedPositions.forEach((pos) => newSplitPositions.delete(pos)); + } else { + selectedPositions.forEach((pos) => newSplitPositions.add(pos)); + } + + const smartSplitCommand = { + execute: () => setSplitPositions(newSplitPositions), + undo: () => setSplitPositions(splitPositions), + description: shouldRemoveSplits + ? `Remove ${selectedPositions.length} split(s)` + : `Add ${selectedPositions.length - existingSplitsCount} split(s)`, + }; + + executeCommandWithTracking(smartSplitCommand); + }, [ + selectedPageIds, + displayDocument, + splitPositions, + setSplitPositions, + getPageNumbersFromIds, + executeCommandWithTracking, + ]); + + const handleSplitAll = handleSplit; + + const handlePageBreak = useCallback(() => { + if (!displayDocument || selectedPageIds.length === 0) return; + + const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); + + const pageBreakCommand = new PageBreakCommand( + selectedPageNumbers, + getEditedDocument, + setEditedDocument + ); + executeCommandWithTracking(pageBreakCommand); + }, [ + displayDocument, + executeCommandWithTracking, + getEditedDocument, + getPageNumbersFromIds, + selectedPageIds, + setEditedDocument, + ]); + + const handlePageBreakAll = handlePageBreak; + + const handleInsertFiles = useCallback( + async ( + files: File[] | StirlingFileStub[], + insertAfterPage: number, + isFromStorage?: boolean + ) => { + const workingDocument = getEditedDocument(); + if (!workingDocument || files.length === 0) return; + + try { + const targetPage = workingDocument.pages.find( + (p) => p.pageNumber === insertAfterPage + ); + if (!targetPage) return; + + const insertAfterPageId = targetPage.id; + let addedFileIds: FileId[] = []; + if (isFromStorage) { + const stubs = files as StirlingFileStub[]; + const result = await actions.addStirlingFileStubs(stubs, { + selectFiles: true, + insertAfterPageId, + }); + addedFileIds = result.map((file) => file.fileId); + } else { + const result = await actions.addFiles(files as File[], { + selectFiles: true, + insertAfterPageId, + }); + addedFileIds = result.map((file) => file.fileId); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const newPages: PDFPage[] = []; + for (const fileId of addedFileIds) { + const stub = selectors.getStirlingFileStub(fileId); + if (stub?.processedFile?.pages) { + const clonedPages = stub.processedFile.pages.map((page, idx) => ({ + ...page, + id: `${fileId}-${page.pageNumber ?? idx + 1}`, + pageNumber: page.pageNumber ?? idx + 1, + originalFileId: fileId, + originalPageNumber: + page.originalPageNumber ?? page.pageNumber ?? idx + 1, + rotation: page.rotation ?? 0, + thumbnail: page.thumbnail ?? null, + selected: false, + splitAfter: page.splitAfter ?? false, + })); + newPages.push(...clonedPages); + } + } + + if (newPages.length > 0) { + const targetIndex = workingDocument.pages.findIndex( + (p) => p.id === targetPage.id + ); + + if (targetIndex >= 0) { + const updatedPages = [...workingDocument.pages]; + updatedPages.splice(targetIndex + 1, 0, ...newPages); + + updatedPages.forEach((page, index) => { + page.pageNumber = index + 1; + }); + + setEditedDocument({ + ...workingDocument, + pages: updatedPages, + }); + + updateFileOrderFromPages(updatedPages); + } + } + } catch (error) { + console.error("Failed to insert files:", error); + } + }, + [ + getEditedDocument, + actions, + selectors, + updateFileOrderFromPages, + setEditedDocument, + ] + ); + + const handleReorderPages = useCallback( + ( + sourcePageNumber: number, + targetIndex: number, + draggedPageIds?: string[] + ) => { + if (!displayDocument) return; + + const selectedPages = draggedPageIds + ? getPageNumbersFromIds(draggedPageIds) + : undefined; + + const reorderCommand = new ReorderPagesCommand( + sourcePageNumber, + targetIndex, + selectedPages, + getEditedDocument, + setEditedDocument, + (newPages) => updateFileOrderFromPages(newPages) + ); + executeCommandWithTracking(reorderCommand); + }, + [ + displayDocument, + getEditedDocument, + executeCommandWithTracking, + getPageNumbersFromIds, + setEditedDocument, + updateFileOrderFromPages, + ] + ); + + return { + createRotateCommand, + createDeleteCommand, + createSplitCommand, + executeCommand, + handleRotate, + handleDelete, + handleDeletePage, + handleSplit, + handleSplitAll, + handlePageBreak, + handlePageBreakAll, + handleInsertFiles, + handleReorderPages, + closePdf, + }; +}; + +export type UsePageEditorCommandsReturn = ReturnType< + typeof usePageEditorCommands +>; diff --git a/frontend/src/core/components/pageEditor/hooks/useFileColorMap.ts b/frontend/src/core/components/pageEditor/hooks/useFileColorMap.ts new file mode 100644 index 0000000000..463289a4e4 --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/useFileColorMap.ts @@ -0,0 +1,33 @@ +import { useMemo, useRef } from 'react'; +import { FileId } from '@app/types/file'; + +/** + * Maintains stable color assignments for a collection of file IDs. + * Colors are assigned by insertion order and preserved across reorders. + */ +export function useFileColorMap(fileIds: FileId[]): Map { + const assignmentsRef = useRef(new Map()); + + const serializedIds = useMemo(() => fileIds.join(','), [fileIds]); + + return useMemo(() => { + const assignments = assignmentsRef.current; + const activeIds = new Set(fileIds); + + // Remove colors for files that no longer exist + for (const id of Array.from(assignments.keys())) { + if (!activeIds.has(id)) { + assignments.delete(id); + } + } + + // Assign colors to any new files + fileIds.forEach((id) => { + if (!assignments.has(id)) { + assignments.set(id, assignments.size); + } + }); + + return assignments; + }, [serializedIds, fileIds]); +} diff --git a/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts b/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts new file mode 100644 index 0000000000..e77e526c9e --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; +import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument'; +import { PDFDocument } from '@app/types/pageEditor'; + +/** + * Hook that calls usePageDocument but only returns the FIRST non-null result + * After initialization, it ignores all subsequent updates + */ +export function useInitialPageDocument(): PDFDocument | null { + const { document: liveDocument } = usePageDocument(); + const [initialDocument, setInitialDocument] = useState(null); + + useEffect(() => { + // Only set once when we get the first non-null document + if (liveDocument && !initialDocument) { + console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages'); + setInitialDocument(liveDocument); + } + }, [liveDocument, initialDocument]); + + return initialDocument; +} diff --git a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts index 1d3ecfb227..40b823bd1c 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import { useFileState } from '@app/contexts/FileContext'; +import { usePageEditor } from '@app/contexts/PageEditorContext'; import { PDFDocument, PDFPage } from '@app/types/pageEditor'; import { FileId } from '@app/types/file'; @@ -15,14 +16,40 @@ export interface PageDocumentHook { */ export function usePageDocument(): PageDocumentHook { const { state, selectors } = useFileState(); + const { fileOrder, currentPages } = usePageEditor(); - // Prefer IDs + selectors to avoid array identity churn - const activeFileIds = state.files.ids; - const primaryFileId = activeFileIds[0] ?? null; + // Use PageEditorContext's fileOrder instead of FileContext's global order + // This ensures the page editor respects its own workspace ordering + const allFileIds = fileOrder; - // Stable signature for effects (prevents loops) + // Derive selected file IDs directly from FileContext (single source of truth) + // Filter to only include PDF files (PageEditor only supports PDFs) + // Use stable string keys to prevent infinite loops + const allFileIdsKey = allFileIds.join(','); + const selectedFileIdsKey = [...state.ui.selectedFileIds].sort().join(','); const activeFilesSignature = selectors.getFilesSignature(); + // Get ALL PDF files (selected or not) for document building with placeholders + const activeFileIds = useMemo(() => { + return allFileIds.filter(id => { + const stub = selectors.getStirlingFileStub(id); + return stub?.name?.toLowerCase().endsWith('.pdf') ?? false; + }); + }, [allFileIdsKey, activeFilesSignature, selectors]); + + const selectedActiveFileIds = useMemo(() => { + if (activeFileIds.length === 0) { + return []; + } + const selectedSet = new Set(state.ui.selectedFileIds); + if (selectedSet.size === 0) { + return []; + } + return activeFileIds.filter((id) => selectedSet.has(id)); + }, [activeFileIds, selectedFileIdsKey]); + + const primaryFileId = selectedActiveFileIds[0] ?? activeFileIds[0] ?? null; + // UI state const globalProcessing = state.ui.isProcessing; @@ -32,6 +59,10 @@ export function usePageDocument(): PageDocumentHook { const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages; // Compute merged document with stable signature (prevents infinite loops) + const currentPagesSignature = useMemo(() => { + return currentPages ? currentPages.map(page => page.id).join(',') : ''; + }, [currentPages]); + const mergedPdfDocument = useMemo((): PDFDocument | null => { if (activeFileIds.length === 0) return null; @@ -43,10 +74,14 @@ export function usePageDocument(): PageDocumentHook { return null; } + const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds; + const name = - activeFileIds.length === 1 - ? (primaryStirlingFileStub.name ?? 'document.pdf') - : activeFileIds + namingFileIds.length <= 1 + ? (namingFileIds[0] + ? selectors.getStirlingFileStub(namingFileIds[0])?.name ?? 'document.pdf' + : 'document.pdf') + : namingFileIds .map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, '')) .join(' + '); @@ -69,13 +104,28 @@ export function usePageDocument(): PageDocumentHook { // Build pages by interleaving original pages with insertions let pages: PDFPage[] = []; - // Helper function to create pages from a file - const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => { + // Helper function to create pages from a file (or placeholder if deselected) + const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => { const stirlingFileStub = selectors.getStirlingFileStub(fileId); if (!stirlingFileStub) { return []; } + // If file is deselected, create a single placeholder page + if (!isSelected) { + return [{ + id: `${fileId}-placeholder`, + pageNumber: startPageNumber, + originalPageNumber: 1, + originalFileId: fileId, + rotation: 0, + thumbnail: null, + selected: false, + splitAfter: false, + isPlaceholder: true, + }]; + } + const processedFile = stirlingFileStub.processedFile; let filePages: PDFPage[] = []; @@ -90,6 +140,7 @@ export function usePageDocument(): PageDocumentHook { splitAfter: page.splitAfter || false, originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1, originalFileId: fileId, + isPlaceholder: false, })); } else if (processedFile?.totalPages) { // Fallback: create pages without thumbnails but with correct count @@ -102,16 +153,30 @@ export function usePageDocument(): PageDocumentHook { thumbnail: null, selected: false, splitAfter: false, + isPlaceholder: false, })); } return filePages; }; - // Collect all pages from original files (without renumbering yet) + // Collect all pages from original files, respecting their previous positions + const selectedFileIdsSet = new Set(state.ui.selectedFileIds); + + // Sort original files by their position in fileOrder (so placeholders stay in correct spot) + // Use fileOrder as source of truth since it persists across page editor sessions + const fileOrderMap = new Map(allFileIds.map((id, index) => [id, index])); + + const sortedOriginalFileIds = [...originalFileIds].sort((a, b) => { + const posA = fileOrderMap.get(a) ?? Number.MAX_SAFE_INTEGER; + const posB = fileOrderMap.get(b) ?? Number.MAX_SAFE_INTEGER; + return posA - posB; + }); + const originalFilePages: PDFPage[] = []; - originalFileIds.forEach(fileId => { - const filePages = createPagesFromFile(fileId, 1); // Temporary numbering + sortedOriginalFileIds.forEach(fileId => { + const isSelected = selectedFileIdsSet.has(fileId); + const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering originalFilePages.push(...filePages); }); @@ -130,7 +195,8 @@ export function usePageDocument(): PageDocumentHook { // Collect all pages to insert const allNewPages: PDFPage[] = []; fileIds.forEach(fileId => { - const insertedPages = createPagesFromFile(fileId, 1); + const isSelected = selectedFileIdsSet.has(fileId); + const insertedPages = createPagesFromFile(fileId, 1, isSelected); allNewPages.push(...insertedPages); }); @@ -147,6 +213,29 @@ export function usePageDocument(): PageDocumentHook { return null; } + // Pages are already in the correct order from the sorted assembly above + // Just ensure page numbers are sequential + pages = pages.map((page, index) => ({ + ...page, + pageNumber: index + 1, + })); + + const currentPagesSet = currentPages ? new Set(currentPages.map(page => page.id)) : null; + if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) { + const sameIds = pages.every(page => currentPagesSet.has(page.id)); + if (sameIds) { + const mergedById = new Map(pages.map(page => [page.id, page])); + pages = currentPages.map((currentPage, index) => { + const source = mergedById.get(currentPage.id); + const mergedPage = source ? { ...source, ...currentPage } : currentPage; + return { + ...mergedPage, + pageNumber: index + 1, + }; + }); + } + } + const mergedDoc: PDFDocument = { id: activeFileIds.join('-'), name, @@ -156,7 +245,7 @@ export function usePageDocument(): PageDocumentHook { }; return mergedDoc; - }, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]); + }, [activeFileIds, selectedActiveFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, allFileIds, currentPagesSignature, currentPages]); // Large document detection for smart loading const isVeryLargeDocument = useMemo(() => { diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts new file mode 100644 index 0000000000..cc8d51725d --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { usePageEditor } from '@app/contexts/PageEditorContext'; +import { useFileState } from '@app/contexts/FileContext'; +import { FileId } from '@app/types/file'; +import { useFileColorMap } from '@app/components/pageEditor/hooks/useFileColorMap'; + +export interface PageEditorDropdownFile { + fileId: FileId; + name: string; + versionNumber?: number; + isSelected: boolean; +} + +export interface PageEditorDropdownState { + files: PageEditorDropdownFile[]; + selectedCount: number; + totalCount: number; + onToggleSelection: (fileId: FileId) => void; + onReorder: (fromIndex: number, toIndex: number) => void; + fileColorMap: Map; +} + +const isPdf = (name?: string | null) => + typeof name === 'string' && name.toLowerCase().endsWith('.pdf'); + +export function usePageEditorDropdownState(): PageEditorDropdownState { + const { state, selectors } = useFileState(); + const { + toggleFileSelection, + reorderFiles, + fileOrder, + } = usePageEditor(); + + const pageEditorFiles = useMemo(() => { + return fileOrder + .map((fileId) => { + const stub = selectors.getStirlingFileStub(fileId); + if (!isPdf(stub?.name)) return null; + + return { + fileId, + name: stub?.name || '', + versionNumber: stub?.versionNumber, + isSelected: state.ui.selectedFileIds.includes(fileId), + }; + }) + .filter((file): file is PageEditorDropdownFile => file !== null); + }, [fileOrder, selectors, state.ui.selectedFileIds]); + + const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId)); + + const selectedCount = useMemo( + () => pageEditorFiles.filter((file) => file.isSelected).length, + [pageEditorFiles] + ); + + return useMemo(() => ({ + files: pageEditorFiles, + selectedCount, + totalCount: pageEditorFiles.length, + onToggleSelection: toggleFileSelection, + onReorder: reorderFiles, + fileColorMap, + }), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]); +} diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts new file mode 100644 index 0000000000..a41027a98c --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -0,0 +1,313 @@ +import { Dispatch, SetStateAction, useCallback } from "react"; + +import type { + useFileActions, + useFileState, +} from "@app/contexts/FileContext"; +import { documentManipulationService } from "@app/services/documentManipulationService"; +import { pdfExportService } from "@app/services/pdfExportService"; +import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers"; +import { FileId } from "@app/types/file"; +import { PDFDocument } from "@app/types/pageEditor"; + +type FileActions = ReturnType["actions"]; +type FileSelectors = ReturnType["selectors"]; + +interface UsePageEditorExportParams { + displayDocument: PDFDocument | null; + selectedPageIds: string[]; + splitPositions: Set; + selectedFileIds: FileId[]; + selectors: FileSelectors; + actions: FileActions; + setHasUnsavedChanges: (dirty: boolean) => void; + exportLoading: boolean; + setExportLoading: (loading: boolean) => void; + setSplitPositions: Dispatch>>; +} + +const removePlaceholderPages = (document: PDFDocument): PDFDocument => { + const filteredPages = document.pages.filter((page) => !page.isPlaceholder); + if (filteredPages.length === document.pages.length) { + return document; + } + + const normalizedPages = filteredPages.map((page, index) => ({ + ...page, + pageNumber: index + 1, + })); + + return { + ...document, + pages: normalizedPages, + totalPages: normalizedPages.length, + }; +}; + +const normalizeProcessedDocuments = ( + processed: PDFDocument | PDFDocument[] +): PDFDocument | PDFDocument[] => { + if (Array.isArray(processed)) { + const normalized = processed + .map(removePlaceholderPages) + .filter((doc) => doc.pages.length > 0); + return normalized; + } + return removePlaceholderPages(processed); +}; + +export const usePageEditorExport = ({ + displayDocument, + selectedPageIds, + splitPositions, + selectedFileIds, + selectors, + actions, + setHasUnsavedChanges, + exportLoading, + setExportLoading, + setSplitPositions, +}: UsePageEditorExportParams) => { + const getSourceFiles = useCallback((): Map | null => { + const sourceFiles = new Map(); + + selectedFileIds.forEach((fileId) => { + const file = selectors.getFile(fileId); + if (file) { + sourceFiles.set(fileId, file); + } + }); + + const hasInsertedFiles = false; + const hasMultipleOriginalFiles = selectedFileIds.length > 1; + + if (!hasInsertedFiles && !hasMultipleOriginalFiles) { + return null; + } + + return sourceFiles.size > 0 ? sourceFiles : null; + }, [selectedFileIds, selectors]); + + const getExportFilename = useCallback((): string => { + if (selectedFileIds.length <= 1) { + return displayDocument?.name || "document.pdf"; + } + + const firstFile = selectors.getFile(selectedFileIds[0]); + if (firstFile) { + const baseName = firstFile.name.replace(/\.pdf$/i, ""); + return `${baseName} (merged).pdf`; + } + + return "merged-document.pdf"; + }, [selectedFileIds, selectors, displayDocument]); + + const onExportSelected = useCallback(async () => { + if (!displayDocument || selectedPageIds.length === 0) return; + + setExportLoading(true); + try { + const processedDocuments = + documentManipulationService.applyDOMChangesToDocument( + displayDocument, + displayDocument, + splitPositions + ); + + const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); + const documentWithDOMState = Array.isArray(normalizedDocuments) + ? normalizedDocuments[0] + : normalizedDocuments; + + if (!documentWithDOMState || documentWithDOMState.pages.length === 0) { + console.warn("Export skipped: no concrete pages available after filtering placeholders."); + setExportLoading(false); + return; + } + + const validSelectedPageIds = selectedPageIds.filter((pageId) => + documentWithDOMState.pages.some((page) => page.id === pageId) + ); + + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile( + documentWithDOMState, + sourceFiles, + validSelectedPageIds, + { selectedOnly: true, filename: exportFilename } + ) + : await pdfExportService.exportPDF( + documentWithDOMState, + validSelectedPageIds, + { selectedOnly: true, filename: exportFilename } + ); + + pdfExportService.downloadFile(result.blob, result.filename); + setHasUnsavedChanges(false); + setSplitPositions(new Set()); + setExportLoading(false); + } catch (error) { + console.error("Export failed:", error); + setExportLoading(false); + } + }, [ + displayDocument, + selectedPageIds, + splitPositions, + getSourceFiles, + getExportFilename, + setHasUnsavedChanges, + setExportLoading, + ]); + + const onExportAll = useCallback(async () => { + if (!displayDocument) return; + + setExportLoading(true); + try { + const processedDocuments = + documentManipulationService.applyDOMChangesToDocument( + displayDocument, + displayDocument, + splitPositions + ); + + const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); + + if ( + (Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) || + (!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0) + ) { + console.warn("Export skipped: no concrete pages available after filtering placeholders."); + setExportLoading(false); + return; + } + + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const files = await exportProcessedDocumentsToFiles( + normalizedDocuments, + sourceFiles, + exportFilename + ); + + if (files.length > 1) { + const JSZip = await import("jszip"); + const zip = new JSZip.default(); + + files.forEach((file) => { + zip.file(file.name, file); + }); + + const zipBlob = await zip.generateAsync({ type: "blob" }); + const zipFilename = exportFilename.replace(/\.pdf$/i, ".zip"); + + pdfExportService.downloadFile(zipBlob, zipFilename); + } else { + const file = files[0]; + pdfExportService.downloadFile(file, file.name); + } + + setHasUnsavedChanges(false); + setSplitPositions(new Set()); + setExportLoading(false); + } catch (error) { + console.error("Export failed:", error); + setExportLoading(false); + } + }, [ + displayDocument, + splitPositions, + getSourceFiles, + getExportFilename, + setHasUnsavedChanges, + setExportLoading, + ]); + + const applyChanges = useCallback(async () => { + if (!displayDocument) return; + + setExportLoading(true); + try { + const processedDocuments = + documentManipulationService.applyDOMChangesToDocument( + displayDocument, + displayDocument, + splitPositions + ); + + const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); + + if ( + (Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) || + (!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0) + ) { + console.warn("Apply changes skipped: no concrete pages available after filtering placeholders."); + setExportLoading(false); + return; + } + + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const files = await exportProcessedDocumentsToFiles( + normalizedDocuments, + sourceFiles, + exportFilename + ); + + // Add "_multitool" suffix to filenames + const renamedFiles = files.map(file => { + const nameParts = file.name.match(/^(.+?)(\.pdf)$/i); + if (nameParts) { + const baseName = nameParts[1]; + const extension = nameParts[2]; + const newName = `${baseName}_multitool${extension}`; + return new File([file], newName, { type: file.type }); + } + return file; + }); + + // Store source file IDs before adding new files + const sourceFileIds = [...selectedFileIds]; + + const newStirlingFiles = await actions.addFiles(renamedFiles, { + selectFiles: true, + }); + if (newStirlingFiles.length > 0) { + actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId)); + } + + // Remove source files from context + if (sourceFileIds.length > 0) { + await actions.removeFiles(sourceFileIds, true); + } + + setHasUnsavedChanges(false); + setSplitPositions(new Set()); + setExportLoading(false); + } catch (error) { + console.error("Apply changes failed:", error); + setExportLoading(false); + } + }, [ + displayDocument, + splitPositions, + getSourceFiles, + getExportFilename, + actions, + selectedFileIds, + setHasUnsavedChanges, + setExportLoading, + ]); + + return { + exportLoading, + onExportSelected, + onExportAll, + applyChanges, + }; +}; + +export type UsePageEditorExportReturn = ReturnType; diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts index 203ff89753..2c37990d16 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts @@ -1,26 +1,27 @@ import { useState, useCallback } from 'react'; +import React from 'react'; export interface PageEditorState { // Selection state selectionMode: boolean; selectedPageIds: string[]; - - // Animation state + + // Animation state movingPage: number | null; isAnimating: boolean; - + // Split state splitPositions: Set; - + // Export state exportLoading: boolean; - + // Actions setSelectionMode: (mode: boolean) => void; setSelectedPageIds: (pages: string[]) => void; setMovingPage: (pageNumber: number | null) => void; setIsAnimating: (animating: boolean) => void; - setSplitPositions: (positions: Set) => void; + setSplitPositions: React.Dispatch>>; setExportLoading: (loading: boolean) => void; // Helper functions diff --git a/frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts b/frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts new file mode 100644 index 0000000000..ac63c5efa9 --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { PDFDocument } from "@app/types/pageEditor"; +import { parseSelection } from "@app/utils/bulkselection/parseSelection"; + +interface UsePageSelectionManagerParams { + displayDocument: PDFDocument | null; + selectedPageIds: string[]; + setSelectedPageIds: (ids: string[]) => void; + setSelectionMode: (enabled: boolean) => void; + toggleSelectAll: (ids: string[]) => void; + activeFilesSignature: string; +} + +export const usePageSelectionManager = ({ + displayDocument, + selectedPageIds, + setSelectedPageIds, + setSelectionMode, + toggleSelectAll, + activeFilesSignature, +}: UsePageSelectionManagerParams) => { + const [csvInput, setCsvInput] = useState(""); + const hasInitializedSelection = useRef(false); + const previousPageIdsRef = useRef>(new Set()); + + const totalPages = displayDocument?.pages.length ?? 0; + + const getPageNumbersFromIds = useCallback( + (pageIds: string[]) => { + if (!displayDocument) return []; + return pageIds + .map((id) => { + const page = displayDocument.pages.find((p) => p.id === id); + return page?.pageNumber || 0; + }) + .filter((num) => num > 0); + }, + [displayDocument] + ); + + const getPageIdsFromNumbers = useCallback( + (pageNumbers: number[]) => { + if (!displayDocument) return []; + return pageNumbers + .map((num) => { + const page = displayDocument.pages.find((p) => p.pageNumber === num); + return page?.id || ""; + }) + .filter((id) => id !== ""); + }, + [displayDocument] + ); + + useEffect(() => { + if ( + displayDocument && + displayDocument.pages.length > 0 && + !hasInitializedSelection.current + ) { + const allPageIds = displayDocument.pages.map((page) => page.id); + setSelectedPageIds(allPageIds); + setSelectionMode(true); + hasInitializedSelection.current = true; + } + }, [displayDocument, setSelectedPageIds, setSelectionMode]); + + useEffect(() => { + if (!displayDocument || displayDocument.pages.length === 0) { + previousPageIdsRef.current = new Set(); + return; + } + + const currentIds = new Set(displayDocument.pages.map((page) => page.id)); + const newlyAddedPageIds: string[] = []; + currentIds.forEach((id) => { + if (!previousPageIdsRef.current.has(id)) { + newlyAddedPageIds.push(id); + } + }); + + if (newlyAddedPageIds.length > 0) { + const next = new Set(selectedPageIds); + newlyAddedPageIds.forEach((id) => next.add(id)); + setSelectedPageIds(Array.from(next)); + } + + previousPageIdsRef.current = currentIds; + }, [displayDocument, selectedPageIds, setSelectedPageIds]); + + useEffect(() => { + setCsvInput(""); + }, [activeFilesSignature]); + + const handleSelectAll = useCallback(() => { + if (!displayDocument) return; + const allPageIds = displayDocument.pages.map((page) => page.id); + toggleSelectAll(allPageIds); + }, [displayDocument, toggleSelectAll]); + + const handleDeselectAll = useCallback(() => { + setSelectedPageIds([]); + }, [setSelectedPageIds]); + + const handleSetSelectedPages = useCallback( + (pageNumbers: number[]) => { + const pageIds = getPageIdsFromNumbers(pageNumbers); + setSelectedPageIds(pageIds); + }, + [getPageIdsFromNumbers, setSelectedPageIds] + ); + + const updatePagesFromCSV = useCallback( + (override?: string) => { + if (totalPages === 0) return; + const normalized = parseSelection(override ?? csvInput, totalPages); + handleSetSelectedPages(normalized); + }, + [csvInput, totalPages, handleSetSelectedPages] + ); + + return { + csvInput, + setCsvInput, + totalPages, + getPageNumbersFromIds, + getPageIdsFromNumbers, + handleSelectAll, + handleDeselectAll, + handleSetSelectedPages, + updatePagesFromCSV, + }; +}; + +export type UsePageSelectionManagerReturn = ReturnType< + typeof usePageSelectionManager +>; diff --git a/frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts b/frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts new file mode 100644 index 0000000000..627742b15f --- /dev/null +++ b/frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { UndoManager } from "@app/components/pageEditor/commands/pageCommands"; + +interface UseUndoManagerStateParams { + setHasUnsavedChanges: (dirty: boolean) => void; +} + +export const useUndoManagerState = ({ + setHasUnsavedChanges, +}: UseUndoManagerStateParams) => { + const undoManagerRef = useRef(new UndoManager()); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + const updateUndoRedoState = useCallback(() => { + const undoManager = undoManagerRef.current; + setCanUndo(undoManager.canUndo()); + setCanRedo(undoManager.canRedo()); + + if (!undoManager.hasHistory()) { + setHasUnsavedChanges(false); + } + }, [setHasUnsavedChanges]); + + useEffect(() => { + undoManagerRef.current.setStateChangeCallback(updateUndoRedoState); + updateUndoRedoState(); + }, [updateUndoRedoState]); + + const executeCommandWithTracking = useCallback( + (command: any) => { + undoManagerRef.current.executeCommand(command); + setHasUnsavedChanges(true); + }, + [setHasUnsavedChanges] + ); + + const handleUndo = useCallback(() => { + undoManagerRef.current.undo(); + }, []); + + const handleRedo = useCallback(() => { + undoManagerRef.current.redo(); + }, []); + + const clearUndoHistory = useCallback(() => { + undoManagerRef.current.clear(); + updateUndoRedoState(); + }, [updateUndoRedoState]); + + return { + canUndo, + canRedo, + executeCommandWithTracking, + handleUndo, + handleRedo, + clearUndoHistory, + }; +}; + +export type UseUndoManagerStateReturn = ReturnType; diff --git a/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx b/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx index 03479c943a..4aae339a73 100644 --- a/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx +++ b/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx @@ -16,6 +16,7 @@ interface PageEditorRightRailButtonsParams { handleDeselectAll: () => void; handleDelete: () => void; onExportSelected: () => void; + onSaveChanges: () => void; exportLoading: boolean; activeFileCount: number; closePdf: () => void; @@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons handleDeselectAll, handleDelete, onExportSelected, + onSaveChanges, exportLoading, activeFileCount, closePdf, @@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers'); const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages'); const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages'); + const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes'); const closePdfLabel = t('rightRail.closePdf', 'Close PDF'); const buttons = useMemo(() => { @@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons visible: totalPages > 0, onClick: onExportSelected, }, + { + id: 'page-save-changes', + icon: , + tooltip: saveChangesLabel, + ariaLabel: saveChangesLabel, + section: 'top' as const, + order: 55, + disabled: totalPages === 0 || exportLoading, + visible: totalPages > 0, + onClick: onSaveChanges, + }, { id: 'page-close-pdf', icon: , @@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons selectByNumberLabel, deleteSelectedLabel, exportSelectedLabel, + saveChangesLabel, closePdfLabel, totalPages, selectedPageCount, @@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons handleDeselectAll, handleDelete, onExportSelected, + onSaveChanges, exportLoading, activeFileCount, closePdf, diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index be13be2d42..e69aa97e86 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -68,8 +68,6 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { const isAdmin = config?.isAdmin ?? false; const runningEE = config?.runningEE ?? false; - console.log('[AppConfigModal] Config:', { isAdmin, runningEE, fullConfig: config }); - // Left navigation structure and icons const configNavSections = useMemo(() => createConfigNavSections( diff --git a/frontend/src/core/components/shared/HoverActionMenu.module.css b/frontend/src/core/components/shared/HoverActionMenu.module.css index 27974f152a..61d17eb404 100644 --- a/frontend/src/core/components/shared/HoverActionMenu.module.css +++ b/frontend/src/core/components/shared/HoverActionMenu.module.css @@ -11,7 +11,6 @@ padding: 6px 12px; border-radius: 20px; box-shadow: var(--shadow-md); - z-index: 30; white-space: nowrap; pointer-events: auto; transition: opacity 0.2s ease-in-out; diff --git a/frontend/src/core/components/shared/HoverActionMenu.tsx b/frontend/src/core/components/shared/HoverActionMenu.tsx index 51b3155b35..eb2304c770 100644 --- a/frontend/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/src/core/components/shared/HoverActionMenu.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ActionIcon, Tooltip } from '@mantine/core'; import styles from '@app/components/shared/HoverActionMenu.module.css'; +import { Z_INDEX_HOVER_ACTION_MENU } from '@app/styles/zIndex'; export interface HoverAction { id: string; @@ -34,7 +35,7 @@ const HoverActionMenu: React.FC = ({ return (
e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} @@ -44,10 +45,10 @@ const HoverActionMenu: React.FC = ({ {action.icon} diff --git a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx new file mode 100644 index 0000000000..161f390dfc --- /dev/null +++ b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx @@ -0,0 +1,234 @@ +import React from 'react'; +import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core'; +import { LocalIcon } from '@app/components/shared/LocalIcon'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import AddIcon from '@mui/icons-material/Add'; +import FitText from '@app/components/shared/FitText'; +import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors'; +import { useFilesModalContext } from '@app/contexts/FilesModalContext'; +import { PrivateContent } from '@app/components/shared/PrivateContent'; +import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop'; + +import { FileId } from '@app/types/file'; + +// Local interface for PageEditor file display +interface PageEditorFile { + fileId: FileId; + name: string; + versionNumber?: number; + isSelected: boolean; +} + +interface FileMenuItemProps { + file: PageEditorFile; + index: number; + colorIndex: number; + onToggleSelection: (fileId: FileId) => void; + onReorder: (fromIndex: number, toIndex: number) => void; +} + +const FileMenuItem: React.FC = ({ + file, + index, + colorIndex, + onToggleSelection, + onReorder, +}) => { + const { + itemRef, + isDragging, + isDragOver, + dropPosition, + movedRef, + onPointerDown, + onPointerMove, + onPointerUp, + } = useFileItemDragDrop({ + fileId: file.fileId, + index, + onReorder, + }); + + const itemName = file?.name || 'Untitled'; + const fileColorBorder = getFileColorWithOpacity(colorIndex, 1); + const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0); + + return ( +
+ {/* Drop indicator line */} + {isDragOver && ( +
+ )} +
{ + e.stopPropagation(); + if (movedRef.current) return; // ignore click after drag + onToggleSelection(file.fileId); + }} + style={{ + padding: '0.75rem 0.75rem', + cursor: isDragging ? 'grabbing' : 'grab', + backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent', + borderLeft: `6px solid ${fileColorBorder}`, + opacity: isDragging ? 0.5 : 1, + transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease', + userSelect: 'none', + }} + onMouseEnter={(e) => { + if (!isDragging) { + (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)'; + (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover; + } + }} + onMouseLeave={(e) => { + if (!isDragging) { + (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'; + (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder; + } + }} + > + +
+ +
+ onToggleSelection(file.fileId)} + onClick={(e) => e.stopPropagation()} + size="sm" + /> +
+ + + +
+ {file.versionNumber && file.versionNumber > 1 && ( + + v{file.versionNumber} + + )} +
+
+
+ ); +}; + +interface PageEditorFileDropdownProps { + files: PageEditorFile[]; + onToggleSelection: (fileId: FileId) => void; + onReorder: (fromIndex: number, toIndex: number) => void; + switchingTo?: string | null; + viewOptionStyle: React.CSSProperties; + fileColorMap: Map; + selectedCount: number; + totalCount: number; +} + +export const PageEditorFileDropdown: React.FC = ({ + files, + onToggleSelection, + onReorder, + switchingTo, + viewOptionStyle, + fileColorMap, + selectedCount, + totalCount, +}) => { + const { openFilesModal } = useFilesModalContext(); + + return ( + + +
+ {switchingTo === "pageEditor" ? ( + + ) : ( + + )} + {selectedCount}/{totalCount} files selected + +
+
+ + {files.map((file, index) => { + const colorIndex = fileColorMap.get(file.fileId as string) ?? 0; + + return ( + + ); + })} + + {/* Add File Button */} +
{ + e.stopPropagation(); + openFilesModal(); + }} + style={{ + padding: '0.75rem 0.75rem', + marginTop: '0.5rem', + cursor: 'pointer', + backgroundColor: 'transparent', + borderTop: '1px solid var(--border-subtle)', + transition: 'background-color 0.15s ease', + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; + }} + > + + + + Add File + + +
+
+
+ ); +}; diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 502430e53d..448b4d9130 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -177,6 +177,7 @@ export const Tooltip: React.FC = ({ // Moving into the tooltip → keep open if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { + (children.props as any)?.onPointerLeave?.(e); return; } diff --git a/frontend/src/core/components/shared/TopControls.tsx b/frontend/src/core/components/shared/TopControls.tsx index e62d00716c..089f5b769d 100644 --- a/frontend/src/core/components/shared/TopControls.tsx +++ b/frontend/src/core/components/shared/TopControls.tsx @@ -1,47 +1,49 @@ -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useMemo } from "react"; import { SegmentedControl, Loader } from "@mantine/core"; -import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; +import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; import rainbowStyles from '@app/styles/rainbow.module.css'; import VisibilityIcon from "@mui/icons-material/Visibility"; -import EditNoteIcon from "@mui/icons-material/EditNote"; import FolderIcon from "@mui/icons-material/Folder"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import { LocalIcon } from '@app/components/shared/LocalIcon'; import { WorkbenchType, isValidWorkbench } from '@app/types/workbench'; +import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown'; import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext'; import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; +import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState'; const viewOptionStyle: React.CSSProperties = { display: 'inline-flex', flexDirection: 'row', alignItems: 'center', - gap: 6, - whiteSpace: 'nowrap', - paddingTop: '0.3rem', + gap: '0.5rem', + justifyContent: 'center', + padding: '2px 1rem', }; - -// Build view options showing text always +// Helper function to create view options for SegmentedControl const createViewOptions = ( currentView: WorkbenchType, switchingTo: WorkbenchType | null, activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>, currentFileIndex: number, onFileSelect?: (index: number) => void, + pageEditorState?: PageEditorDropdownState, customViews?: CustomWorkbenchViewInstance[] ) => { + // Viewer dropdown logic const currentFile = activeFiles[currentFileIndex]; const isInViewer = currentView === 'viewer'; const fileName = currentFile?.name || ''; - const displayName = isInViewer && fileName ? fileName : 'Viewer'; + const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer'; const hasMultipleFiles = activeFiles.length > 1; - const showDropdown = isInViewer && hasMultipleFiles; + const showViewerDropdown = isInViewer && hasMultipleFiles; const viewerOption = { - label: showDropdown ? ( + label: showViewerDropdown ? ( {switchingTo === "viewer" ? ( - + ) : ( - + )} - {displayName}
), value: "viewer", }; + // Page Editor dropdown logic + const isInPageEditor = currentView === 'pageEditor'; + const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0; + const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles; + const pageEditorOption = { - label: ( + label: showPageEditorDropdown ? ( + + ) : (
- {currentView === "pageEditor" ? ( - <> - {switchingTo === "pageEditor" ? : } - Page Editor - + {switchingTo === "pageEditor" ? ( + ) : ( - <> - {switchingTo === "pageEditor" ? : } - Page Editor - + )}
), @@ -83,17 +94,7 @@ const createViewOptions = ( const fileEditorOption = { label: (
- {currentView === "fileEditor" ? ( - <> - {switchingTo === "fileEditor" ? : } - Active Files - - ) : ( - <> - {switchingTo === "fileEditor" ? : } - Active Files - - )} + {switchingTo === "fileEditor" ? : }
), value: "fileEditor", @@ -111,9 +112,9 @@ const createViewOptions = ( label: (
{switchingTo === view.workbenchId ? ( - + ) : ( - view.icon || + view.icon || )} {view.label}
@@ -144,6 +145,8 @@ const TopControls = ({ const { isRainbowMode } = useRainbowThemeContext(); const [switchingTo, setSwitchingTo] = useState(null); + const pageEditorState = usePageEditorDropdownState(); + const handleViewChange = useCallback((view: string) => { if (!isValidWorkbench(view)) { return; @@ -166,13 +169,25 @@ const TopControls = ({ }); }, [setCurrentView]); + // Memoize view options to prevent SegmentedControl re-renders + const viewOptions = useMemo(() => createViewOptions( + currentView, + switchingTo, + activeFiles, + currentFileIndex, + onFileSelect, + pageEditorState, + customViews + ), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]); + return (
-
+
diff --git a/frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts b/frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts new file mode 100644 index 0000000000..2503957de8 --- /dev/null +++ b/frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts @@ -0,0 +1,154 @@ +import { useRef, useEffect, useState } from 'react'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; + +import { FileId } from '@app/types/file'; + +interface UseFileItemDragDropParams { + fileId: FileId; + index: number; + onReorder: (fromIndex: number, toIndex: number) => void; +} + +interface UseFileItemDragDropReturn { + itemRef: React.RefObject; + isDragging: boolean; + isDragOver: boolean; + dropPosition: 'above' | 'below'; + movedRef: React.MutableRefObject; + startRef: React.MutableRefObject<{ x: number; y: number } | null>; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: () => void; +} + +/** + * Hook to handle drag and drop functionality for file items in a list. + * Manages drag state, drop zones, and reordering logic using Pragmatic Drag and Drop. + */ +export const useFileItemDragDrop = ({ + fileId, + index, + onReorder, +}: UseFileItemDragDropParams): UseFileItemDragDropReturn => { + const [isDragging, setIsDragging] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below'); + const itemRef = useRef(null); + + // Keep latest values without re-registering DnD + const indexRef = useRef(index); + const fileIdRef = useRef(fileId); + const dropPositionRef = useRef<'above' | 'below'>('below'); + const onReorderRef = useRef(onReorder); + + useEffect(() => { indexRef.current = index; }, [index]); + useEffect(() => { fileIdRef.current = fileId; }, [fileId]); + useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]); + useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]); + + // Gesture guard for row click vs drag + const movedRef = useRef(false); + const startRef = useRef<{ x: number; y: number } | null>(null); + + const onPointerDown = (e: React.PointerEvent) => { + startRef.current = { x: e.clientX, y: e.clientY }; + movedRef.current = false; + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!startRef.current) return; + const dx = e.clientX - startRef.current.x; + const dy = e.clientY - startRef.current.y; + if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold + }; + + const onPointerUp = () => { + startRef.current = null; + }; + + useEffect(() => { + const element = itemRef.current; + if (!element) return; + + const dragCleanup = draggable({ + element, + getInitialData: () => ({ + type: 'file-item', + fileId: fileIdRef.current, + fromIndex: indexRef.current, + }), + onDragStart: () => setIsDragging((p) => (p ? p : true)), + onDrop: () => setIsDragging((p) => (p ? false : p)), + canDrag: () => true, + }); + + const dropCleanup = dropTargetForElements({ + element, + getData: () => ({ + type: 'file-item', + fileId: fileIdRef.current, + toIndex: indexRef.current, + }), + onDragEnter: () => setIsDragOver((p) => (p ? p : true)), + onDragLeave: () => { + setIsDragOver((p) => (p ? false : p)); + setDropPosition('below'); + }, + onDrag: ({ source }) => { + // Determine drop position based on cursor location + const element = itemRef.current; + if (!element) return; + + const rect = element.getBoundingClientRect(); + const clientY = (source as any).element?.getBoundingClientRect().top || 0; + const midpoint = rect.top + rect.height / 2; + + setDropPosition(clientY < midpoint ? 'below' : 'above'); + }, + onDrop: ({ source }) => { + setIsDragOver(false); + const dropPos = dropPositionRef.current; + setDropPosition('below'); + const sourceData = source.data as any; + if (sourceData?.type === 'file-item') { + const fromIndex = sourceData.fromIndex as number; + let toIndex = indexRef.current; + + // Adjust toIndex based on drop position + if (dropPos === 'below' && fromIndex < toIndex) { + // Dragging down, drop after target - no adjustment needed + } else if (dropPos === 'above' && fromIndex > toIndex) { + // Dragging up, drop before target - no adjustment needed + } else if (dropPos === 'below' && fromIndex > toIndex) { + // Dragging up but want below target + toIndex = toIndex + 1; + } else if (dropPos === 'above' && fromIndex < toIndex) { + // Dragging down but want above target + toIndex = toIndex - 1; + } + + if (fromIndex !== toIndex) { + onReorderRef.current(fromIndex, toIndex); + } + } + } + }); + + return () => { + try { dragCleanup(); } catch { /* cleanup */ } + try { dropCleanup(); } catch { /* cleanup */ } + }; + }, []); // Stable - no dependencies + + return { + itemRef, + isDragging, + isDragOver, + dropPosition, + movedRef, + startRef, + onPointerDown, + onPointerMove, + onPointerUp, + }; +}; diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css index 78e562859f..fc58fa829b 100644 --- a/frontend/src/core/components/tools/compare/compareView.css +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -23,6 +23,7 @@ flex-direction: column; gap: 1.5rem; padding: 1rem; + padding-top: 3rem; height: 100%; min-height: 0; /* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */ diff --git a/frontend/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx index 86b7881839..04eada6403 100644 --- a/frontend/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx +++ b/frontend/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx @@ -174,5 +174,3 @@ export default function OverlayPdfsSettings({ parameters, onParameterChange, dis ); } - - diff --git a/frontend/src/core/components/tooltips/useSplitSettingsTips.ts b/frontend/src/core/components/tooltips/useSplitSettingsTips.ts index 5368063486..6a1b56ca9f 100644 --- a/frontend/src/core/components/tooltips/useSplitSettingsTips.ts +++ b/frontend/src/core/components/tooltips/useSplitSettingsTips.ts @@ -131,4 +131,4 @@ export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent | }; return tooltipMap[method]; -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 5ec3e6eb75..7c2c836238 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -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 { useWheelZoom } from '@app/hooks/useWheelZoom'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -122,39 +123,11 @@ const EmbedPdfViewerContent = ({ } }, [previewFile, fileWithUrl]); - // Handle scroll wheel zoom with accumulator for smooth trackpad pinch - useEffect(() => { - let accumulator = 0; - - const handleWheel = (event: WheelEvent) => { - // Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed - if (event.ctrlKey || event.metaKey) { - event.preventDefault(); - event.stopPropagation(); - - accumulator += event.deltaY; - const threshold = 10; - - if (accumulator <= -threshold) { - // Accumulated scroll up - zoom in - zoomActions.zoomIn(); - accumulator = 0; - } else if (accumulator >= threshold) { - // Accumulated scroll down - zoom out - zoomActions.zoomOut(); - accumulator = 0; - } - } - }; - - const viewerElement = viewerRef.current; - if (viewerElement) { - viewerElement.addEventListener('wheel', handleWheel, { passive: false }); - return () => { - viewerElement.removeEventListener('wheel', handleWheel); - }; - } - }, [zoomActions]); + useWheelZoom({ + ref: viewerRef, + onZoomIn: zoomActions.zoomIn, + onZoomOut: zoomActions.zoomOut, + }); // Handle keyboard zoom shortcuts useEffect(() => { diff --git a/frontend/src/core/contexts/PageEditorContext.tsx b/frontend/src/core/contexts/PageEditorContext.tsx new file mode 100644 index 0000000000..6881db1ed0 --- /dev/null +++ b/frontend/src/core/contexts/PageEditorContext.tsx @@ -0,0 +1,359 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo, useRef, useEffect } from 'react'; +import { FileId } from '@app/types/file'; +import { useFileActions, useFileState } from '@app/contexts/FileContext'; +import { PDFPage } from '@app/types/pageEditor'; +import { MAX_PAGE_EDITOR_FILES } from '@app/components/pageEditor/fileColors'; + +// PageEditorFile is now defined locally in consuming components +// Components should derive file list directly from FileContext + +/** + * Computes file order based on the position of each file's first page + * @param pages - Current page order + * @returns Array of FileIds in order based on first page positions + */ +function computeFileOrderFromPages(pages: PDFPage[]): FileId[] { + // Find the first page for each file + const fileFirstPagePositions = new Map(); + + pages.forEach((page, index) => { + const fileId = page.originalFileId; + if (!fileId) return; + + if (!fileFirstPagePositions.has(fileId)) { + fileFirstPagePositions.set(fileId, index); + } + }); + + // Sort files by their first page position + const fileOrder = Array.from(fileFirstPagePositions.entries()) + .sort((a, b) => a[1] - b[1]) + .map(entry => entry[0]); + + return fileOrder; +} + +/** + * Reorders pages based on file reordering while preserving interlacing and manual page order + * @param currentPages - Current page order (may include manual reordering and interlacing) + * @param fromIndex - Source file index in the file order + * @param toIndex - Target file index in the file order + * @param orderedFileIds - File IDs in their current order + * @returns Reordered pages with updated page numbers + */ +function reorderPagesForFileMove( + currentPages: PDFPage[], + fromIndex: number, + toIndex: number, + orderedFileIds: FileId[] +): PDFPage[] { + // Get the file ID being moved + const movedFileId = orderedFileIds[fromIndex]; + const targetFileId = orderedFileIds[toIndex]; + + // Extract pages belonging to the moved file (maintaining their relative order) + const movedFilePages: PDFPage[] = []; + const remainingPages: PDFPage[] = []; + + currentPages.forEach(page => { + if (page.originalFileId === movedFileId) { + movedFilePages.push(page); + } else { + remainingPages.push(page); + } + }); + + // Find the insertion point based on the target file + let insertionIndex = 0; + + if (fromIndex < toIndex) { + // Moving down: insert AFTER the last page of ANY file that should come before us + // We need to find the last page belonging to any file at index <= toIndex in orderedFileIds + const filesBeforeUs = new Set(orderedFileIds.slice(0, toIndex + 1)); + for (let i = remainingPages.length - 1; i >= 0; i--) { + const pageFileId = remainingPages[i].originalFileId; + if (pageFileId && filesBeforeUs.has(pageFileId)) { + insertionIndex = i + 1; + break; + } + } + } else { + // Moving up: insert BEFORE the first page of target file + for (let i = 0; i < remainingPages.length; i++) { + if (remainingPages[i].originalFileId === targetFileId) { + insertionIndex = i; + break; + } + } + } + + // Insert moved pages at the calculated position + const reorderedPages = [ + ...remainingPages.slice(0, insertionIndex), + ...movedFilePages, + ...remainingPages.slice(insertionIndex) + ]; + + // Renumber all pages sequentially (clone to avoid mutation) + return reorderedPages.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); +} + +interface PageEditorContextValue { + // Current page order (updated by PageEditor, used for file reordering) + currentPages: PDFPage[] | null; + updateCurrentPages: (pages: PDFPage[] | null) => void; + + // Reordered pages (when file reordering happens) + reorderedPages: PDFPage[] | null; + clearReorderedPages: () => void; + + // Page editor's own file order (independent of FileContext global order) + fileOrder: FileId[]; + setFileOrder: (order: FileId[]) => void; + + // Set file selection (calls FileContext actions) + setFileSelection: (fileId: FileId, selected: boolean) => void; + + // Toggle file selection (calls FileContext actions) + toggleFileSelection: (fileId: FileId) => void; + + // Select/deselect all files (calls FileContext actions) + selectAll: () => void; + deselectAll: () => void; + + // Reorder files (only affects page editor's local order) + reorderFiles: (fromIndex: number, toIndex: number) => void; + + // Update file order based on page positions (when pages are manually reordered) + updateFileOrderFromPages: (pages: PDFPage[]) => void; +} + +const PageEditorContext = createContext(undefined); + +interface PageEditorProviderProps { + children: ReactNode; +} + +export function PageEditorProvider({ children }: PageEditorProviderProps) { + const [currentPages, setCurrentPages] = useState(null); + const [reorderedPages, setReorderedPages] = useState(null); + + // Page editor's own file order (independent of FileContext) + const [fileOrder, setFileOrder] = useState([]); + + // Read from FileContext (for file metadata only, not order) + const { actions: fileActions } = useFileActions(); + const { state } = useFileState(); + + // Keep a ref to always read latest state in stable callbacks + const stateRef = useRef(state); + useEffect(() => { + stateRef.current = state; + }, [state]); + + // Track the previous FileContext order to detect actual changes + const prevFileContextIdsRef = useRef([]); + + // Initialize fileOrder from FileContext when files change (add/remove only) + useEffect(() => { + const currentFileIds = state.files.ids; + const prevFileIds = prevFileContextIdsRef.current; + + // Only react to FileContext changes, not our own fileOrder changes + const fileContextChanged = + currentFileIds.length !== prevFileIds.length || + !currentFileIds.every((id, idx) => id === prevFileIds[idx]); + + if (!fileContextChanged) { + return; + } + + prevFileContextIdsRef.current = currentFileIds; + + // Collect new file IDs outside the setState callback so we can clear them after + let newFileIdsToProcess: FileId[] = []; + + // Use functional setState to read latest fileOrder without depending on it + setFileOrder(currentOrder => { + // Identify new files + const newFileIds = currentFileIds.filter(id => !currentOrder.includes(id)); + newFileIdsToProcess = newFileIds; // Store for cleanup + + // Remove deleted files + const validFileOrder = currentOrder.filter(id => currentFileIds.includes(id)); + + if (newFileIds.length === 0 && validFileOrder.length === currentOrder.length) { + return currentOrder; // No changes needed + } + + // Always append new files to end + // If files have insertAfterPageId, page-level insertion is handled by usePageDocument + return [...validFileOrder, ...newFileIds]; + }); + + // Clear insertAfterPageId after a delay to allow usePageDocument to consume it first + setTimeout(() => { + newFileIdsToProcess.forEach(fileId => { + const stub = state.files.byId[fileId]; + if (stub?.insertAfterPageId) { + fileActions.updateStirlingFileStub(fileId, { insertAfterPageId: undefined }); + } + }); + }, 100); + }, [state.files.ids, state.files.byId, fileActions]); + + const updateCurrentPages = useCallback((pages: PDFPage[] | null) => { + setCurrentPages(pages); + }, []); + + const clearReorderedPages = useCallback(() => { + setReorderedPages(null); + }, []); + + const setFileSelection = useCallback((fileId: FileId, selected: boolean) => { + const currentSelection = stateRef.current.ui.selectedFileIds; + const isAlreadySelected = currentSelection.includes(fileId); + + // Check if we're trying to select when at limit + if (selected && !isAlreadySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) { + console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`); + return; + } + + // Update FileContext selection + const newSelectedIds = selected + ? [...currentSelection, fileId] + : currentSelection.filter(id => id !== fileId); + + fileActions.setSelectedFiles(newSelectedIds); + }, [fileActions]); + + const toggleFileSelection = useCallback((fileId: FileId) => { + const currentSelection = stateRef.current.ui.selectedFileIds; + const isCurrentlySelected = currentSelection.includes(fileId); + + // If toggling on and at limit, don't allow + if (!isCurrentlySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) { + console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`); + return; + } + + // Update FileContext selection + const newSelectedIds = isCurrentlySelected + ? currentSelection.filter(id => id !== fileId) + : [...currentSelection, fileId]; + + fileActions.setSelectedFiles(newSelectedIds); + }, [fileActions]); + + const selectAll = useCallback(() => { + const allFileIds = stateRef.current.files.ids; + + if (allFileIds.length > MAX_PAGE_EDITOR_FILES) { + console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`); + fileActions.setSelectedFiles(allFileIds.slice(0, MAX_PAGE_EDITOR_FILES)); + } else { + fileActions.setSelectedFiles(allFileIds); + } + }, [fileActions]); + + const deselectAll = useCallback(() => { + fileActions.setSelectedFiles([]); + }, [fileActions]); + + const reorderFiles = useCallback((fromIndex: number, toIndex: number) => { + // Reorder local fileOrder array (page editor workspace only) + const newOrder = [...fileOrder]; + const [movedFileId] = newOrder.splice(fromIndex, 1); + newOrder.splice(toIndex, 0, movedFileId); + setFileOrder(newOrder); + + // If current pages available, reorder them based on file move + if (currentPages && currentPages.length > 0 && fromIndex !== toIndex) { + // Get the current file order from pages (files that have pages loaded) + const currentFileOrder: FileId[] = []; + const filesSeen = new Set(); + currentPages.forEach(page => { + const fileId = page.originalFileId; + if (fileId && !filesSeen.has(fileId)) { + filesSeen.add(fileId); + currentFileOrder.push(fileId); + } + }); + + // Get the target file ID from the NEW order (after the move) + // When moving down: we want to position after the file at toIndex-1 (file just before insertion) + // When moving up: we want to position before the file at toIndex+1 (file just after insertion) + const targetFileId = fromIndex < toIndex + ? newOrder[toIndex - 1] // Moving down: target is the file just before where we inserted + : newOrder[toIndex + 1]; // Moving up: target is the file just after where we inserted + + // Find their positions in the current page order (not the full file list) + const pageOrderFromIndex = currentFileOrder.findIndex(id => id === movedFileId); + const pageOrderToIndex = currentFileOrder.findIndex(id => id === targetFileId); + + // Only reorder pages if both files have pages loaded + if (pageOrderFromIndex >= 0 && pageOrderToIndex >= 0) { + const reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder); + setReorderedPages(reorderedPagesResult); + } + } + }, [fileOrder, currentPages]); + + const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => { + if (!pages || pages.length === 0) return; + + // Compute the new file order based on page positions + const newFileOrder = computeFileOrderFromPages(pages); + + if (newFileOrder.length > 0) { + // Update local page editor file order (not FileContext) + setFileOrder(newFileOrder); + } + }, []); + + + const value: PageEditorContextValue = useMemo(() => ({ + currentPages, + updateCurrentPages, + reorderedPages, + clearReorderedPages, + fileOrder, + setFileOrder, + setFileSelection, + toggleFileSelection, + selectAll, + deselectAll, + reorderFiles, + updateFileOrderFromPages, + }), [ + currentPages, + updateCurrentPages, + reorderedPages, + clearReorderedPages, + fileOrder, + setFileSelection, + toggleFileSelection, + selectAll, + deselectAll, + reorderFiles, + updateFileOrderFromPages, + ]); + + return ( + + {children} + + ); +} + +export function usePageEditor() { + const context = useContext(PageEditorContext); + if (!context) { + throw new Error('usePageEditor must be used within PageEditorProvider'); + } + return context; +} diff --git a/frontend/src/core/contexts/file/FileReducer.ts b/frontend/src/core/contexts/file/FileReducer.ts index 2baa22a861..db567b1288 100644 --- a/frontend/src/core/contexts/file/FileReducer.ts +++ b/frontend/src/core/contexts/file/FileReducer.ts @@ -149,8 +149,10 @@ export function fileContextReducer(state: FileContextState, action: FileContextA // Validate that all IDs exist in current state const validIds = orderedFileIds.filter(id => state.files.byId[id]); + // Reorder selected files by passed order const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id)); + return { ...state, files: { diff --git a/frontend/src/core/hooks/useWheelZoom.ts b/frontend/src/core/hooks/useWheelZoom.ts new file mode 100644 index 0000000000..66dd2e2941 --- /dev/null +++ b/frontend/src/core/hooks/useWheelZoom.ts @@ -0,0 +1,83 @@ +import { RefObject, useEffect } from 'react'; + +interface UseWheelZoomOptions { + /** + * Element the wheel listener should be bound to. + */ + ref: RefObject; + /** + * Callback executed when the hook decides to zoom in. + */ + onZoomIn: () => void; + /** + * Callback executed when the hook decides to zoom out. + */ + onZoomOut: () => void; + /** + * Whether the wheel listener should be active. + */ + enabled?: boolean; + /** + * How much delta needs to accumulate before a zoom action is triggered. + * Defaults to 10 which matches the previous implementations. + */ + threshold?: number; + /** + * Whether a Ctrl/Cmd modifier is required for zooming. Defaults to true so + * we only react to pinch gestures and intentional ctrl+wheel zooming. + */ + requireModifierKey?: boolean; +} + +/** + * Shared hook for handling wheel-based zoom across components. + * It normalises accumulated delta behaviour, prevents default scrolling when + * zoom is triggered, and keeps the handler detached when disabled. + */ +export function useWheelZoom({ + ref, + onZoomIn, + onZoomOut, + enabled = true, + threshold = 10, + requireModifierKey = true, +}: UseWheelZoomOptions) { + useEffect(() => { + if (!enabled) { + return; + } + + const element = ref.current; + if (!element) { + return; + } + + let accumulator = 0; + + const handleWheel = (event: Event) => { + const wheelEvent = event as WheelEvent; + const hasModifier = wheelEvent.ctrlKey || wheelEvent.metaKey; + if (requireModifierKey && !hasModifier) { + return; + } + + wheelEvent.preventDefault(); + wheelEvent.stopPropagation(); + + accumulator += wheelEvent.deltaY; + + if (accumulator <= -threshold) { + onZoomIn(); + accumulator = 0; + } else if (accumulator >= threshold) { + onZoomOut(); + accumulator = 0; + } + }; + + element.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + element.removeEventListener('wheel', handleWheel); + }; + }, [ref, onZoomIn, onZoomOut, enabled, threshold, requireModifierKey]); +} diff --git a/frontend/src/core/styles/zIndex.ts b/frontend/src/core/styles/zIndex.ts index f1bc929dd3..aeb69e9b57 100644 --- a/frontend/src/core/styles/zIndex.ts +++ b/frontend/src/core/styles/zIndex.ts @@ -10,6 +10,11 @@ export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300; export const Z_INDEX_AUTOMATE_MODAL = 1100; +// page editor Zindexes +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) export const Z_INDEX_OVER_CONFIG_MODAL = 2000; @@ -17,4 +22,3 @@ export const Z_INDEX_OVER_CONFIG_MODAL = 2000; export const Z_INDEX_TOAST = 10001; - diff --git a/frontend/src/core/tools/Split.tsx b/frontend/src/core/tools/Split.tsx index 769a25a089..3b311a5cfc 100644 --- a/frontend/src/core/tools/Split.tsx +++ b/frontend/src/core/tools/Split.tsx @@ -50,8 +50,7 @@ const Split = (props: BaseToolProps) => { { title: t("split.steps.chooseMethod", "Choose Method"), isCollapsed: !!base.params.parameters.method, // Collapse when method is selected - onCollapsedClick: () => base.params.updateParameter('method', '') - , + onCollapsedClick: () => base.params.updateParameter('method', ''), tooltip: methodTips, content: ( @@ -86,7 +85,7 @@ const Split = (props: BaseToolProps) => { review: { isVisible: base.hasResults, operation: base.operation, - title: "Split Results", + title: t("split.resultsTitle", "Split Results"), onFileClick: base.handleThumbnailClick, onUndo: base.handleUndo, }, diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index 3ec4380cc4..ed2734039c 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -89,9 +89,18 @@ export function isStirlingFile(file: File): file is StirlingFile { // Create a StirlingFile from a regular File object export function createStirlingFile(file: File, id?: FileId): StirlingFile { - // Check if file is already a StirlingFile to avoid property redefinition + // If the file already has Stirling metadata and we aren't trying to override it, + // return as–is. When a new id is requested we clone the File so we can embed + // the fresh identifier without mutating the original object. if (isStirlingFile(file)) { - return file; // Already has fileId and quickKey properties + if (!id || file.fileId === id) { + return file; + } + + file = new File([file], file.name, { + type: file.type, + lastModified: file.lastModified, + }); } const fileId = id || createFileId(); diff --git a/frontend/src/core/types/pageEditor.ts b/frontend/src/core/types/pageEditor.ts index 5c46ed0631..b50b6613c1 100644 --- a/frontend/src/core/types/pageEditor.ts +++ b/frontend/src/core/types/pageEditor.ts @@ -1,5 +1,13 @@ import { FileId } from '@app/types/file'; +export type PageSize = 'A4' | 'Letter' | 'Legal' | 'A3' | 'A5'; +export type PageOrientation = 'portrait' | 'landscape'; + +export interface PageBreakSettings { + size: PageSize; + orientation: PageOrientation; +} + export interface PDFPage { id: string; pageNumber: number; @@ -9,7 +17,9 @@ export interface PDFPage { selected: boolean; splitAfter?: boolean; isBlankPage?: boolean; + isPlaceholder?: boolean; originalFileId?: FileId; + pageBreakSettings?: PageBreakSettings; } export interface PDFDocument { From 350fdcf29ac4fa2b63220f53e88e9c4532146500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Sz=C3=BCcs?= <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:57:14 +0100 Subject: [PATCH 10/16] [V2] feat(merge): implement natural sorting for filenames in merge tool (#4888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes TLDR: - Added `naturalCompare` function to handle alphanumeric sorting - Updated `sortFiles` logic to use `naturalCompare` for filename sorting - Passed `naturalCompare` as dependency in sorting callback This pull request improves the file sorting logic in the `Merge` tool to provide a more natural, human-friendly ordering of filenames (e.g., "file2" now comes before "file10" instead of after). The main change is the introduction of a custom `naturalCompare` function that is used when sorting files by filename. File sorting improvements: * Added a `naturalCompare` function to sort filenames in a way that handles numeric portions naturally, ensuring files like "file2" are ordered before "file10" (`frontend/src/core/tools/Merge.tsx`). * Updated the file sorting logic to use `naturalCompare` instead of the default `localeCompare` when sorting by filename (`frontend/src/core/tools/Merge.tsx`). * Ensured the `sortFiles` callback properly depends on the new `naturalCompare` function (`frontend/src/core/tools/Merge.tsx`). Note: the sort on upload is natural sort (at least I think so I haven't checked the code), this is only relevant after upload, and you click the sort button again ### Before: image ### After: image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] 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) - [X] I have performed a self-review of my own code - [X] 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) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] 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. --------- Signed-off-by: Balázs Szücs --- frontend/src/core/tools/Merge.tsx | 55 +++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/tools/Merge.tsx b/frontend/src/core/tools/Merge.tsx index e725291dbb..05d358f700 100644 --- a/frontend/src/core/tools/Merge.tsx +++ b/frontend/src/core/tools/Merge.tsx @@ -26,6 +26,57 @@ const Merge = (props: BaseToolProps) => { props, { minFiles: 2 } ); + const naturalCompare = useCallback((a: string, b: string): number => { + const isDigit = (char: string) => char >= '0' && char <= '9'; + + const getChunk = (s: string, length: number, marker: number): { chunk: string; newMarker: number } => { + let chunk = ''; + const c = s.charAt(marker); + chunk += c; + marker++; + + if (isDigit(c)) { + while (marker < length && isDigit(s.charAt(marker))) { + chunk += s.charAt(marker); + marker++; + } + } else { + while (marker < length && !isDigit(s.charAt(marker))) { + chunk += s.charAt(marker); + marker++; + } + } + return { chunk, newMarker: marker }; + }; + + const len1 = a.length; + const len2 = b.length; + let marker1 = 0; + let marker2 = 0; + + while (marker1 < len1 && marker2 < len2) { + const { chunk: chunk1, newMarker: newMarker1 } = getChunk(a, len1, marker1); + marker1 = newMarker1; + + const { chunk: chunk2, newMarker: newMarker2 } = getChunk(b, len2, marker2); + marker2 = newMarker2; + + let result: number; + if (isDigit(chunk1.charAt(0)) && isDigit(chunk2.charAt(0))) { + const num1 = parseInt(chunk1, 10); + const num2 = parseInt(chunk2, 10); + result = num1 - num2; + } else { + result = chunk1.localeCompare(chunk2); + } + + if (result !== 0) { + return result; + } + } + + return len1 - len2; + }, []); // Custom file sorting logic for merge tool const sortFiles = useCallback((sortType: 'filename' | 'dateModified', ascending: boolean = true) => { @@ -33,7 +84,7 @@ const Merge = (props: BaseToolProps) => { let comparison = 0; switch (sortType) { case 'filename': - comparison = stubA.name.localeCompare(stubB.name); + comparison = naturalCompare(stubA.name, stubB.name); break; case 'dateModified': comparison = stubA.lastModified - stubB.lastModified; @@ -45,7 +96,7 @@ const Merge = (props: BaseToolProps) => { const selectedIds = sortedStubs.map(record => record.id); const deselectedIds = fileIds.filter(id => !selectedIds.includes(id)); reorderFiles([...selectedIds, ...deselectedIds]); - }, [selectedFileStubs, fileIds, reorderFiles]); + }, [selectedFileStubs, fileIds, reorderFiles, naturalCompare]); return createToolFlow({ files: { From 052a3ae653348ee0db7f766e611a856ae848a13e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:29:53 +0000 Subject: [PATCH 11/16] api (#4892) # Description of Changes image --- ## 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. --- .../public/locales/en-GB/translation.json | 6 + .../core/components/shared/AppConfigModal.tsx | 8 +- .../shared/config/configNavSections.tsx | 3 +- frontend/src/core/styles/theme.css | 18 +++ .../shared/config/configNavSections.tsx | 23 +++- .../shared/config/configSections/ApiKeys.tsx | 126 ++++++++++++++++++ .../configSections/apiKeys/ApiKeySection.tsx | 75 +++++++++++ .../configSections/apiKeys/RefreshModal.tsx | 50 +++++++ .../configSections/apiKeys/hooks/useApiKey.ts | 64 +++++++++ 9 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2665b34200..9cfe5380bc 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -4650,6 +4650,12 @@ } }, "apiKeys": { + "intro": "Use your API key to programmatically access Stirling PDF's processing capabilities.", + "docsTitle": "API Documentation", + "docsDescription": "Learn more about integrating with Stirling PDF:", + "docsLink": "API Documentation", + "schemaLink": "API Schema Reference", + "usage": "Include this key in the X-API-KEY header with all API requests.", "description": "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.", "publicKeyAriaLabel": "Public API key", "copyKeyAriaLabel": "Copy API key", diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index e69aa97e86..f9937f775f 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -64,17 +64,19 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { headerBorder: 'var(--modal-header-border)', }), []); - // Get isAdmin and runningEE from app config + // Get isAdmin, runningEE, and loginEnabled from app config const isAdmin = config?.isAdmin ?? false; const runningEE = config?.runningEE ?? false; + const loginEnabled = config?.enableLogin ?? false; // Left navigation structure and icons const configNavSections = useMemo(() => createConfigNavSections( isAdmin, - runningEE + runningEE, + loginEnabled ), - [isAdmin, runningEE] + [isAdmin, runningEE, loginEnabled] ); const activeLabel = useMemo(() => { diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index cbef8d27ec..a1f66480b8 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -41,7 +41,8 @@ export interface ConfigColors { export const createConfigNavSections = ( isAdmin: boolean = false, - runningEE: boolean = false + runningEE: boolean = false, + _loginEnabled: boolean = false ): ConfigNavSection[] => { const sections: ConfigNavSection[] = [ { diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index 3051ced06e..5e8d4087b1 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -279,6 +279,15 @@ --modal-content-bg: #ffffff; --modal-header-border: rgba(0, 0, 0, 0.06); + /* API Keys section colors (light mode) */ + --api-keys-card-bg: #ffffff; + --api-keys-card-border: #e0e0e0; + --api-keys-card-shadow: rgba(0, 0, 0, 0.06); + --api-keys-input-bg: #f8f8f8; + --api-keys-input-border: #e0e0e0; + --api-keys-button-bg: #f5f5f5; + --api-keys-button-color: #333333; + /* PDF Report Colors (always light) */ --pdf-light-header-bg: 239 246 255; --pdf-light-accent: 59 130 246; @@ -539,6 +548,15 @@ --modal-content-bg: #2A2F36; --modal-header-border: rgba(255, 255, 255, 0.08); + /* API Keys section colors (dark mode) */ + --api-keys-card-bg: #2A2F36; + --api-keys-card-border: #3A4047; + --api-keys-card-shadow: none; + --api-keys-input-bg: #1F2329; + --api-keys-input-border: #3A4047; + --api-keys-button-bg: #3A4047; + --api-keys-button-color: #D0D6DC; + /* Code token colors (dark mode - Cursor-like) */ --code-kw-color: #C792EA; /* purple */ --code-str-color: #C3E88D; /* green */ diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx index 1df66b4096..9be7640cc5 100644 --- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx @@ -2,13 +2,15 @@ import React from 'react'; import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections'; import PeopleSection from '@app/components/shared/config/configSections/PeopleSection'; import TeamsSection from '@app/components/shared/config/configSections/TeamsSection'; +import ApiKeys from '@app/components/shared/config/configSections/ApiKeys'; /** * Proprietary extension of createConfigNavSections that adds workspace sections */ export const createConfigNavSections = ( isAdmin: boolean = false, - runningEE: boolean = false + runningEE: boolean = false, + loginEnabled: boolean = false ): ConfigNavSection[] => { // Get the core sections const sections = createCoreConfigNavSections(isAdmin, runningEE); @@ -37,6 +39,25 @@ export const createConfigNavSections = ( sections.splice(1, 0, workspaceSection); } + // Add Developer section if login is enabled + if (loginEnabled) { + const developerSection: ConfigNavSection = { + title: 'Developer', + items: [ + { + key: 'api-keys', + label: 'API Keys', + icon: 'key-rounded', + component: + }, + ], + }; + + // Add Developer section after Preferences (or Workspace if it exists) + const insertIndex = isAdmin ? 2 : 1; + sections.splice(insertIndex, 0, developerSection); + } + return sections; }; diff --git a/frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx b/frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx new file mode 100644 index 0000000000..fcc9108875 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/ApiKeys.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { Anchor, Group, Stack, Text, Paper, Skeleton } from "@mantine/core"; +// eslint-disable-next-line no-restricted-imports +import ApiKeySection from "./apiKeys/ApiKeySection"; +// eslint-disable-next-line no-restricted-imports +import RefreshModal from "./apiKeys/RefreshModal"; +// eslint-disable-next-line no-restricted-imports +import useApiKey from "./apiKeys/hooks/useApiKey"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; + +export default function ApiKeys() { + const [copied, setCopied] = useState(null); + const [showRefreshModal, setShowRefreshModal] = useState(false); + const { t } = useTranslation(); + + const { apiKey, isLoading: apiKeyLoading, refresh, isRefreshing, error: apiKeyError, refetch } = useApiKey(); + + const copy = async (text: string, tag: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(tag); + setTimeout(() => setCopied(null), 1600); + } catch (e) { + console.error(e); + } + }; + + const refreshKeys = async () => { + try { + await refresh(); + } finally { + setShowRefreshModal(false); + } + }; + + return ( + + + {t('config.apiKeys.intro', 'Use your API key to programmatically access Stirling PDF\'s processing capabilities.')} + + + + + + + + {t('config.apiKeys.docsTitle', 'API Documentation')} + + + {t('config.apiKeys.docsDescription', 'Learn more about integrating with Stirling PDF:')} + + + + + {t('config.apiKeys.docsLink', 'API Documentation')} + + + + + + {t('config.apiKeys.schemaLink', 'API Schema Reference')} + + + + + + + + + {apiKeyError && ( + + {t('config.apiKeys.generateError', "We couldn't generate your API key.")} {" "} + + {t('common.retry', 'Retry')} + + + )} + + {apiKeyLoading ? ( +
+ + + + + +
+ ) : ( + setShowRefreshModal(true)} + disabled={isRefreshing} + /> + )} + + + {t('config.apiKeys.usage', 'Include this key in the X-API-KEY header with all API requests.')} + + + setShowRefreshModal(false)} + onConfirm={refreshKeys} + /> +
+ ); +} diff --git a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx new file mode 100644 index 0000000000..e258dc6dca --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { + Box, + Button, + Group, + Paper, +} from "@mantine/core"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import FitText from "@app/components/shared/FitText"; +import { useTranslation } from "react-i18next"; + +interface ApiKeySectionProps { + publicKey: string; + copied: string | null; + onCopy: (text: string, tag: string) => void; + onRefresh: () => void; + disabled?: boolean; +} + +export default function ApiKeySection({ + publicKey, + copied, + onCopy, + onRefresh, + disabled, +}: ApiKeySectionProps) { + const { t } = useTranslation(); + return ( + <> + + + + + + + + + + + + + ); +} diff --git a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx new file mode 100644 index 0000000000..faa6e60b49 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { + Modal, + Stack, + Text, + Group, + Button, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; + +interface RefreshModalProps { + opened: boolean; + onClose: () => void; + onConfirm: () => void; +} + +export default function RefreshModal({ opened, onClose, onConfirm }: RefreshModalProps) { + const { t } = useTranslation(); + return ( + + + + {t('config.apiKeys.refreshModal.warning', '⚠️ Warning: This action will generate new API keys and make your previous keys invalid.')} + + + {t('config.apiKeys.refreshModal.impact', 'Any applications or services currently using these keys will stop working until you update them with the new keys.')} + + + {t('config.apiKeys.refreshModal.confirmPrompt', 'Are you sure you want to continue?')} + + + + + + + + ); +} diff --git a/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts new file mode 100644 index 0000000000..c8efe73f4c --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/apiKeys/hooks/useApiKey.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; +import apiClient from "@app/services/apiClient"; + +export function useApiKey() { + const [apiKey, setApiKey] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const [hasAttempted, setHasAttempted] = useState(false); + + const fetchKey = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + // Backend is POST for get and update + const res = await apiClient.post("/api/v1/user/get-api-key"); + const value = typeof res.data === "string" ? res.data : res.data?.apiKey; + if (typeof value === "string") setApiKey(value); + } catch (e: any) { + // If not found, try to create one by calling update endpoint + if (e?.response?.status === 404) { + try { + const createRes = await apiClient.post("/api/v1/user/update-api-key"); + const created = + typeof createRes.data === "string" + ? createRes.data + : createRes.data?.apiKey; + if (typeof created === "string") setApiKey(created); + } catch (createErr: any) { + setError(createErr); + } + } else { + setError(e); + } + } finally { + setIsLoading(false); + setHasAttempted(true); + } + }, []); + + const refresh = useCallback(async () => { + setIsRefreshing(true); + setError(null); + try { + const res = await apiClient.post("/api/v1/user/update-api-key"); + const value = typeof res.data === "string" ? res.data : res.data?.apiKey; + if (typeof value === "string") setApiKey(value); + } catch (e: any) { + setError(e); + } finally { + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + if (!hasAttempted) { + fetchKey(); + } + }, [hasAttempted, fetchKey]); + + return { apiKey, isLoading, isRefreshing, error, refetch: fetchKey, refresh, hasAttempted } as const; +} + +export default useApiKey; From edba81ff9dd1dc4c2cd9f3f1ba3bb146ad3fd710 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:46:51 +0000 Subject: [PATCH 12/16] :globe_with_meridians: [V2] Sync Translations + Update README Progress Table (#4754) ### Description of Changes This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made: #### **1. Synchronization of Translation Files** - Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`. - Ensured consistency and synchronization across all supported language files. - Highlighted any missing or incomplete translations. #### **2. Update README.md** - Generated the translation progress table in `README.md`. - Added a summary of the current translation status for all supported languages. - Included up-to-date statistics on translation coverage. #### **Why these changes are necessary** - Keeps translation files aligned with the latest reference updates. - Ensures the documentation reflects the current translation progress. --- Auto-generated by [create-pull-request][1]. [1]: https://github.com/peter-evans/create-pull-request Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- README.md | 74 ++++++++++++++++----------------- scripts/ignore_translation.toml | 1 - 2 files changed, 37 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index f8058d9060..211301b99b 100644 --- a/README.md +++ b/README.md @@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![32%](https://geps.dev/progress/32) | -| Basque (Euskara) (eu_ES) | ![18%](https://geps.dev/progress/18) | -| Bulgarian (Български) (bg_BG) | ![35%](https://geps.dev/progress/35) | -| Catalan (Català) (ca_CA) | ![34%](https://geps.dev/progress/34) | -| Croatian (Hrvatski) (hr_HR) | ![31%](https://geps.dev/progress/31) | -| Czech (Česky) (cs_CZ) | ![34%](https://geps.dev/progress/34) | -| Danish (Dansk) (da_DK) | ![30%](https://geps.dev/progress/30) | -| Dutch (Nederlands) (nl_NL) | ![30%](https://geps.dev/progress/30) | +| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) | +| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) | +| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) | +| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) | +| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) | +| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) | +| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) | +| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) | +| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![82%](https://geps.dev/progress/82) | -| German (Deutsch) (de_DE) | ![84%](https://geps.dev/progress/84) | -| Greek (Ελληνικά) (el_GR) | ![34%](https://geps.dev/progress/34) | -| Hindi (हिंदी) (hi_IN) | ![34%](https://geps.dev/progress/34) | -| Hungarian (Magyar) (hu_HU) | ![38%](https://geps.dev/progress/38) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![31%](https://geps.dev/progress/31) | -| Irish (Gaeilge) (ga_IE) | ![34%](https://geps.dev/progress/34) | -| Italian (Italiano) (it_IT) | ![84%](https://geps.dev/progress/84) | -| Japanese (日本語) (ja_JP) | ![62%](https://geps.dev/progress/62) | -| Korean (한국어) (ko_KR) | ![34%](https://geps.dev/progress/34) | -| Norwegian (Norsk) (no_NB) | ![32%](https://geps.dev/progress/32) | -| Persian (فارسی) (fa_IR) | ![34%](https://geps.dev/progress/34) | -| Polish (Polski) (pl_PL) | ![36%](https://geps.dev/progress/36) | -| Portuguese (Português) (pt_PT) | ![34%](https://geps.dev/progress/34) | -| Portuguese Brazilian (Português) (pt_BR) | ![83%](https://geps.dev/progress/83) | -| Romanian (Română) (ro_RO) | ![28%](https://geps.dev/progress/28) | -| Russian (Русский) (ru_RU) | ![83%](https://geps.dev/progress/83) | -| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![37%](https://geps.dev/progress/37) | -| Simplified Chinese (简体中文) (zh_CN) | ![85%](https://geps.dev/progress/85) | -| Slovakian (Slovensky) (sk_SK) | ![26%](https://geps.dev/progress/26) | -| Slovenian (Slovenščina) (sl_SI) | ![36%](https://geps.dev/progress/36) | -| Spanish (Español) (es_ES) | ![84%](https://geps.dev/progress/84) | -| Swedish (Svenska) (sv_SE) | ![33%](https://geps.dev/progress/33) | -| Thai (ไทย) (th_TH) | ![31%](https://geps.dev/progress/31) | +| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) | +| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) | +| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) | +| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) | +| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) | +| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) | +| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) | +| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) | +| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) | +| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) | +| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) | +| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) | +| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) | +| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) | +| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) | +| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) | +| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) | +| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) | +| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) | +| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) | +| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) | +| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) | +| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) | | Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) | -| Traditional Chinese (繁體中文) (zh_TW) | ![38%](https://geps.dev/progress/38) | -| Turkish (Türkçe) (tr_TR) | ![37%](https://geps.dev/progress/37) | -| Ukrainian (Українська) (uk_UA) | ![36%](https://geps.dev/progress/36) | -| Vietnamese (Tiếng Việt) (vi_VN) | ![28%](https://geps.dev/progress/28) | +| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) | +| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) | +| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) | +| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) | | Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) | ## Stirling PDF Enterprise diff --git a/scripts/ignore_translation.toml b/scripts/ignore_translation.toml index f0d1d7d2bb..83e2df925a 100644 --- a/scripts/ignore_translation.toml +++ b/scripts/ignore_translation.toml @@ -242,7 +242,6 @@ ignore = [ 'team.status', 'text', 'update.version', - 'validateSignature.cert.bits', 'validateSignature.cert.version', 'validateSignature.status', 'watermark.type.1', From 50760b5302251821913ee872b9a10db2683c14c2 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 13 Nov 2025 18:04:09 +0000 Subject: [PATCH 13/16] quick fix to compare (#4893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Compare fix to stop the pages from getting stuck in loading state. This code tracks which page images have actually finished loading in a Map stored in a ref, and then uses a throwaway useState value (setImageLoadedTick) just to force a re-render when that ref changes. When an fires onLoad, it marks that page as loaded in imageLoadedRef and bumps the tick, causing React to re-render and re-evaluate the condition for the loader overlay. Because the overlay is only shown when imageLoadedRef.current.get(page.pageNumber) is false, that re-render hides the blur/loader once the page’s image has loaded and is in view, instead of the UI getting stuck in the “loading” state. Screenshot 2025-11-13 at 5 57 42 PM --- ## 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. --- .../core/components/tools/compare/CompareDocumentPane.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx index 78f9a4b75c..18adf6ac6e 100644 --- a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -1,5 +1,5 @@ import { Group, Loader, Stack, Text } from '@mantine/core'; -import { useMemo, useRef, useEffect } from 'react'; +import { useMemo, useRef, useEffect, useState } from 'react'; import type { PagePreview } from '@app/types/compare'; import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare'; import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare'; @@ -53,6 +53,8 @@ const CompareDocumentPane = ({ // Track which page images have finished loading to avoid flashing between states const imageLoadedRef = useRef>(new Map()); + // Force a re-render when an image load state changes (refs don't trigger renders) + const [, setImageLoadedTick] = useState(0); const visiblePageRafRef = useRef(null); const lastReportedVisiblePageRef = useRef(null); const pageNodesRef = useRef(null); @@ -252,6 +254,7 @@ const CompareDocumentPane = ({ onLoad={() => { if (!imageLoadedRef.current.get(page.pageNumber)) { imageLoadedRef.current.set(page.pageNumber, true); + setImageLoadedTick((v) => v + 1); // refs don't trigger renders } }} /> From 1117ce6164df60c240ab6e1aadadd02acfa7bb71 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:02:45 +0000 Subject: [PATCH 14/16] Settings display demo and login fix (#4884) # Description of Changes image image image --- ## 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. --- .dockerignore | 74 ++++++++ .../common/model/ApplicationProperties.java | 9 + .../software/common/util/RequestUriUtils.java | 3 + .../controller/api/misc/ConfigController.java | 11 +- .../src/main/resources/settings.yml.template | 1 + .../api/ProprietaryUIDataController.java | 5 + .../filter/UserAuthenticationFilter.java | 3 +- build.gradle | 2 +- docker/unified/nginx.conf | 3 + frontend/public/branding/old/favicon.ico | Bin 0 -> 15086 bytes frontend/public/branding/old/favicon.png | Bin 0 -> 8860 bytes frontend/public/branding/old/favicon.svg | 1 + .../public/locales/en-GB/translation.json | 13 ++ .../core/components/shared/AppConfigModal.tsx | 11 +- .../components/shared/FirstLoginModal.tsx | 2 +- .../core/components/shared/LandingPage.tsx | 4 +- .../shared/config/LoginRequiredBanner.tsx | 38 ++++ .../shared/config/configNavSections.tsx | 123 +------------ .../config/configSections/ProviderCard.tsx | 10 +- .../tools/FullscreenToolSurface.tsx | 5 +- .../src/core/contexts/AppConfigContext.tsx | 1 + frontend/src/core/hooks/useLoginRequired.ts | 89 ++++++++++ frontend/src/core/hooks/useLogoPath.ts | 31 ++++ frontend/src/core/pages/HomePage.tsx | 5 +- frontend/src/core/services/accountService.ts | 10 ++ .../src/core/services/httpErrorHandler.ts | 27 ++- .../src/proprietary/auth/springAuthClient.ts | 2 +- .../components/shared/LoginRightCarousel.tsx | 6 +- .../shared/config/configNavSections.tsx | 162 ++++++++++++++++-- .../configSections/AdminAdvancedSection.tsx | 94 ++++++++-- .../configSections/AdminAuditSection.tsx | 36 +++- .../AdminConnectionsSection.tsx | 90 ++++++---- .../configSections/AdminDatabaseSection.tsx | 54 ++++-- .../configSections/AdminEndpointsSection.tsx | 34 +++- .../configSections/AdminFeaturesSection.tsx | 47 +++-- .../configSections/AdminGeneralSection.tsx | 124 +++++++++++--- .../configSections/AdminLegalSection.tsx | 24 ++- .../configSections/AdminMailSection.tsx | 0 .../configSections/AdminPremiumSection.tsx | 27 ++- .../configSections/AdminPrivacySection.tsx | 49 ++++-- .../configSections/AdminSecuritySection.tsx | 141 +++++++++------ .../configSections/AdminUsageSection.tsx | 74 +++++++- .../config/configSections/PeopleSection.tsx | 132 +++++++++++--- .../config/configSections/TeamsSection.tsx | 34 ++-- .../audit/AuditChartsSection.tsx | 35 +++- .../configSections/audit/AuditEventsTable.tsx | 61 ++++++- .../audit/AuditExportSection.tsx | 17 +- .../configSections/audit/AuditFiltersForm.tsx | 8 +- .../audit/AuditSystemStatus.tsx | 0 .../usage/UsageAnalyticsChart.tsx | 0 .../usage/UsageAnalyticsTable.tsx | 0 frontend/src/proprietary/routes/Landing.tsx | 12 +- frontend/src/proprietary/routes/Login.tsx | 125 +++++++++----- .../proprietary/routes/authShared/auth.css | 2 +- .../routes/login/LoggedInState.tsx | 10 +- .../proprietary/routes/login/LoginHeader.tsx | 1 - .../proprietary/routes/login/OAuthButtons.tsx | 52 ++++-- .../services/userManagementService.ts | 0 58 files changed, 1475 insertions(+), 459 deletions(-) create mode 100644 .dockerignore create mode 100644 frontend/public/branding/old/favicon.ico create mode 100644 frontend/public/branding/old/favicon.png create mode 100644 frontend/public/branding/old/favicon.svg create mode 100644 frontend/src/core/components/shared/config/LoginRequiredBanner.tsx create mode 100644 frontend/src/core/hooks/useLoginRequired.ts create mode 100644 frontend/src/core/hooks/useLogoPath.ts rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminAdvancedSection.tsx (91%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminAuditSection.tsx (72%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminConnectionsSection.tsx (85%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminDatabaseSection.tsx (85%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminEndpointsSection.tsx (82%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminFeaturesSection.tsx (83%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminGeneralSection.tsx (81%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminLegalSection.tsx (89%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminMailSection.tsx (100%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminPremiumSection.tsx (84%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminPrivacySection.tsx (78%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminSecuritySection.tsx (85%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/AdminUsageSection.tsx (62%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/audit/AuditChartsSection.tsx (81%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/audit/AuditEventsTable.tsx (80%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/audit/AuditExportSection.tsx (86%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/audit/AuditFiltersForm.tsx (91%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/audit/AuditSystemStatus.tsx (100%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/usage/UsageAnalyticsChart.tsx (100%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx (100%) rename frontend/src/{core => proprietary}/services/userManagementService.ts (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..54b49fd807 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,74 @@ +# Node modules and build artifacts +node_modules +frontend/node_modules +frontend/dist +frontend/build +frontend/.vite +frontend/.tauri + +# Gradle build artifacts +.gradle +build +bin +target +out + +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.iml +*.iws +*.ipr + +# Logs +*.log +logs + +# Environment files +.env +.env.* +!.env.example + +# OS files +.DS_Store +Thumbs.db + +# Java compiled files +*.class +*.jar +*.war +*.ear + +# Test reports +test-results +coverage + +# Docker +docker-compose.override.yml +.dockerignore + +# Temporary files +tmp +temp +*.tmp +*.swp +*~ + +# Runtime database and config files (locked by running app) +app/core/configs/** +stirling/** +stirling-pdf-DB*.mv.db +stirling-pdf-DB*.trace.db + +# Documentation +*.md +!README.md +docs + +# CI/CD +.github +.gitlab-ci.yml diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 4b5a202c06..6a6ee8453b 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -505,10 +505,19 @@ public class ApplicationProperties { public static class Ui { private String appNameNavbar; private List languages; + private String logoStyle = "classic"; // Options: "classic" (default) or "modern" public String getAppNameNavbar() { return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null; } + + public String getLogoStyle() { + // Validate and return either "modern" or "classic" + if ("modern".equalsIgnoreCase(logoStyle)) { + return "modern"; + } + return "classic"; // default + } } @Data diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 23d369bf37..a0d7f36109 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -75,6 +75,9 @@ public class RequestUriUtils { || trimmedUri.startsWith("/api/v1/auth/login") || trimmedUri.startsWith("/api/v1/auth/refresh") || trimmedUri.startsWith("/api/v1/auth/logout") + || trimmedUri.startsWith( + "/api/v1/proprietary/ui-data/login") // Login page config (SSO providers + + // enableLogin) || trimmedUri.startsWith("/v1/api-docs") || trimmedUri.startsWith("/api/v1/invite/validate") || trimmedUri.startsWith("/api/v1/invite/accept") diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 02b5233b8b..ffbec5a7d8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestParam; import io.swagger.v3.oas.annotations.Hidden; +import lombok.extern.slf4j.Slf4j; + import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.InitialSetup; import stirling.software.common.annotations.api.ConfigApi; @@ -20,6 +22,7 @@ import stirling.software.common.service.UserServiceInterface; @ConfigApi @Hidden +@Slf4j public class ConfigController { private final ApplicationProperties applicationProperties; @@ -59,9 +62,15 @@ public class ConfigController { // Extract values from ApplicationProperties configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar()); configData.put("languages", applicationProperties.getUi().getLanguages()); + configData.put("logoStyle", applicationProperties.getUi().getLogoStyle()); // Security settings - configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin()); + // enableLogin requires both the config flag AND proprietary features to be loaded + // If userService is null, proprietary module isn't loaded + // (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false) + boolean enableLogin = + applicationProperties.getSecurity().getEnableLogin() && userService != null; + configData.put("enableLogin", enableLogin); // Mail settings - check both SMTP enabled AND invites enabled boolean smtpEnabled = applicationProperties.getMail().isEnabled(); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 5f8ca51be0..139260872c 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -176,6 +176,7 @@ system: ui: appNameNavbar: '' # name displayed on the navigation bar + logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled. endpoints: diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index ed9106f2f1..a5e0b8a0f4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -116,6 +116,10 @@ public class ProprietaryUIDataController { LoginData data = new LoginData(); Map providerList = new HashMap<>(); Security securityProps = applicationProperties.getSecurity(); + + // Add enableLogin flag so frontend doesn't need to call /app-config + data.setEnableLogin(securityProps.getEnableLogin()); + OAUTH2 oauth = securityProps.getOauth2(); if (oauth != null && oauth.getEnabled()) { @@ -448,6 +452,7 @@ public class ProprietaryUIDataController { @Data public static class LoginData { + private Boolean enableLogin; private Map providerList; private String loginMethod; private boolean altLogin; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index cdf7bdd0d5..9d1dbc96fd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { || trimmedUri.startsWith("/saml2") || trimmedUri.startsWith("/api/v1/auth/login") || trimmedUri.startsWith("/api/v1/auth/refresh") - || trimmedUri.startsWith("/api/v1/auth/logout"); + || trimmedUri.startsWith("/api/v1/auth/logout") + || trimmedUri.startsWith("/api/v1/proprietary/ui-data/login"); } private enum UserLoginType { diff --git a/build.gradle b/build.gradle index 9acb8c9f00..2451aa45c7 100644 --- a/build.gradle +++ b/build.gradle @@ -57,7 +57,7 @@ repositories { allprojects { group = 'stirling.software' - version = '1.4.0' + version = '2.0.0' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/docker/unified/nginx.conf b/docker/unified/nginx.conf index 77ee17f89c..1e47b8619a 100644 --- a/docker/unified/nginx.conf +++ b/docker/unified/nginx.conf @@ -1,3 +1,6 @@ +# Run nginx as non-root user +pid /tmp/nginx.pid; + events { worker_connections 1024; } diff --git a/frontend/public/branding/old/favicon.ico b/frontend/public/branding/old/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8ad57cac700ed029414ee1060d8bfe038bd3f460 GIT binary patch literal 15086 zcmZQzU}RusFfaho3Jfb$85qnM7#I{3pnL%ahI^_E3<3fWeg+EzLz@``g9ZZwg8>5r zLjnUtoB;$>K*T79g@B02Ze~TrgL2Bs2OO1@4kjrp9&A=oI=qrn1t3-O5S_ZYwJt_@%6L;6FN6S3B_g#?>1q zKYh6U|Kt1HcR#(mHS@#U+o2ynUKOI3Kjh>N3xdK@S?R!cboUX%2l?U3g{!CDy}9`x zgx|it`Ty;!8-L!tx^d>ss~a7!UtITn{rUzkE&QXRvW;C?>A)3Y+=)+(hT6eDm(E=| z{rctg|F523gX5RauKfq$7f-MLe);t3nU_zmb-a9f&E@LVt8CQpkFxTCTzu{#MNVDq z;GgrSE}wq>zJKXI3_rSe=|2cRynFFK7~i@0|G}LL5PbW> z|NFNt=9B0Cg9iw?iBx$|JRCoA;T+g~Ah*HrtqcF}-8%pO-c1O;d*j@H7`}7;+`_@) zevp4b@o@CO>GLr6f#}=U&i;qtTUXEihvA!7&iuc5<;<1AU`vXRK#&GwzoTiQ7?ep z55gcn9N2y0D#)EzFP!>+_58{IS3vmO$^Tc*o&10K+=>5}&qDF3V^*|szqjYn|7|U2 z|L@*)`Txu3H~+tXgNkq8xc-0o)N}t$O%7xD1?GoKXHNVF;ftq_|A*lVr;hyx;qxbs z{y%r}crLZwUtV_V|Aq6{!1WTSZo-AZb=K>f{~tfR{y!@67*;>**?#;g$ekd3_W05N z==jXBBMYeMel4wopn4Q1e)Hi@5S~znC2h?EzjkdtbOq#I zEcnm)Lx;FX@dLj6YhiW>#eJZ5z{=(4|NHtJ{jaKW0E`pjkNv-M^D4Z(fZTS-$~caZ zj=*^uzaK29<$inH!(exT+zoOgwsZ}0PfF4Wa2p4t4uoGkz4{-7L4F0vW5YT+dw#B4 zzVjWHILM}!`$1(S$Q>ZJgUTOl?f{8_@(H-j18Vmm;d7@iVGVzf9%ZFH|Mhjd|8H8e z`xUYuP8>QkpIYt*xf#^<3JX4p#~la$$Hg22x7A>6HV_Tccj+8{_p7ROVEBR5Jd7{> zgVK3l_i2J|zj5^vyln|-v4qpz%Ob9>(W>NIYYfA(rNc{)6g; zgZnOk+QB&S>J?}3_bRW`I zF37{I0}x??>VA+v4D|Z{?cTEg!O261h|a^rxF1xGg4_*izvIH-Ha)CufA`}5BL~j? z2ep$B?nRGBO!ve5Fu89V(e*GP_nVm={D11$d2pDcyYb{ML17HTAb0EQ9>iHT zg8ZYa19K-Xn)dDorF~d`6-I;lg81AHsvAxmIrATX*$MI!ZuhIIOnFL;^aBcKSRWfk zUpapU-0sAdUO?f#d)p~cU;KYbA-=L7zx!2`r#+&E`zyGXdX29FtBI{hCM2i@(*{zpe1`5zm7_c5gCJw@mfYJ#leSpSm&@p)Y1~i5P z!mx3ivnT!!CijE<;Olkh|E{ga;bTppaU6WOr2+St7RWq&=|@Fn#xYn7D_Jg1)95ByhA-uo5Q zPgGUu`3J6ZRbcgS|5?ynIXP}7qz63zu5>V5S?OS}veJR0vERY!=HhpHO@8Wau;@{sTV(f>e^C(pp} zzX23b3=IE43hY7Qd4Pcr6rTV8G4O-w{}1FL^nXxzf`k1(WSy1*0|SEtC|nspECvP! z0g$1TV-1Zx!W%czW{nKUbq)^uSybf@YAPx1^Hx^epQEI7091yrS5Z1}L0Rd*M^L#} zT72gCrw?~eeEM*^$_h+zuxfjJ}xX1E$JK2mTSV7g@fv=;SHTdLQtd(&MZDpFO_v z|JkD}pgE4uPaj_1{rKUf@@J4WGmM1X16~J)W!)FDec0GV1;t+&K6D&h>LB>pyN^J>5!6`$2BZ z&pvwo#^uw$L1QrB@fYZt4e(gNxsy96vESY8$p41=GygZPzx4n7nQQ;gow@dZ>7on& zV`EU~ZF4dYU$}hs#2-k17twFLaO&u5a_skTJM#bVfh+&ty}k*@ko5;Q;B);;7M=gE zriy5f7v&zhbnf`kzu+|o$Bz6zbM(mn(?<{gKXc@W96mQ-i~s!GlUU~EuS4d#UtItH z8j73hQRn^&a}S<9dFaqz=o}Y#&A`dShXYBqzqI(&|5wki|9|oH`u~|T&iywxKlEQs z_2B>PjN|{GKZdNg1IZlV^IDF&4GgKMc}>v(^HmwK?;ZMq}tzDf9ij6;R%d3rn%Xn|JN>E2A4IU@&<%K zbAAZBLFr*Hc)Tqub=ilLhYtNadFT+{G>9$!ZLANWj3L`t9s1u;fAasO^A|DZLS3BE z=dmF6gZdmf=_}rX+;HmPL0Md3fX#lGe}V&#{@=9zEO=cXY#tL*-a+Osj~=@4Uqu<@ zXJm}9ALNGItd%bg?>&Gs4dSxDqU`wp`?sOxD$=|oXg&{wZ(hClKfoW`7#6DiAUBwq zELcs7|J_^;|G#?!G8YFf%aP{aK>S_X&iwauM-O*c7+~73qB46IDfUn7JNX|r4+Wy{ z-aP+*->x(NTboY&_w&K#XPDg}8jJnPvvKADY~jCo!)b6`0;*3yI4d1z^AGglYTnDgoX5nIIR_9MmRArorv0kQv)7SgzmM0U|JN>@2G3Vq1J$P& zPyIi3@Z|r~$4>qS^Py`ZV`H$T1x))@RA!Tu{y~1}>pF%qj*l{a4_(`s7)MO#lkNvN!I`ZGd7~32xmhe}ejdSg=vhqPEkbkL%G3{4XnL7`c|3Ts^ zN(a}_(tc&-c{4!v;KHE354aBkS*!b%GPhv*UrlXhJTAM@#X)XSQ8{F!qIfV{S^2;s zWu*hRNORA=uPQ3Dj;bh6d8ew}_gz(a@)ib${{;IX=< zUS*{NpYXWpA+lcDuvalFDIL^RRz8@fqI6)Mvhsmz&_3KRC8hltw6PPvsVXWb`1Hhg z616@YJYLXH|NjRA`~MH%VHyVe2Mi4ISTO$s1_pG@{(yl2FaE&5zz)NI7#R5fA7J4B z|A*oK|NjrcR)hAFfrceOBNU9VaS9fE{`=3Utb9;bRq>#mvhu-r6{Q1>Ygb=Q|MLEp z0ik^%>gq>$m6Z-$MNcm##)sZMd3f#Ct7q3xJ=+4G+G% zdG-8lP@4$cPJ!%~FhTZ-ipqWmn0gTH;&kMHZqBLyx!EWG8yOz_Z(?-t{rQup?t7P0M9K8PpT33Dj%GLiC=7;|q8y@&@bl>5-rw$+ff8x-gS-AACUvvI{ zW8LZh+S&*I_w}6l|MKaz|Gm)llg38--t61H|H-LChtA^C9~=Z}2RPUt{=a$Sx&Kce zUj9FG8mtet@4t!Bgn#?CAGm*e+ctKX8OZ(z%?GYqbr!rPyfGv>-_&~m(KrRyzmrSTM(ij)ZSH5nV*GC|IveI!0jqiQ%Jp) zlyLOF8N3aL(66jGKNXw)Me~k>_hn4%J@&t=9n?NJ_21qeVwSQJLcfyIyhLpJ?Q9SI zKeXor%DxC^M@;={D)VDu`V|!q>4DM>3~Oo}_#Y8==zm7)q5t6_hmhKa2>Vr)7DU4I zgJ>nC18piw2j0TWz)mCdD=E!!2I)h^{}~k(_o;*OpR&@y>CkbxKS*YMRa2SYp{hKy z3soO(p0@UW9#HyFQrcgkqPRZ>UFF~Z3=9Y485j<*LoqW0!vP}jA7;>)DFXul%Bf_> literal 0 HcmV?d00001 diff --git a/frontend/public/branding/old/favicon.png b/frontend/public/branding/old/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..5edc6eae26832dfa8d113dc54db899515d7c1fc2 GIT binary patch literal 8860 zcmeAS@N?(olHy`uVBq!ia0y~yU}6Aa4rT@hhQrHLPB1Vqy$&p{>Sljm<8R8rcnsC+1-GtMn0}(K6U8O$qrT39@El>m4d8mmfXtRZ;CwQ8{}2+=W~ByHr$9oV`3* zS>?v7H&<@oJ-221xwRY4En9JJ?t-%&ooDJAP8XNne*f|8)mvREss|MnuRMBsZpyTi z=dPT}%D(>M%>gClJ0CtxQ&ze0;`QzKAFn@ub@{=QbJy=&e)#m-(-*hjeYo=Y*+E6+ zMM;Qc$eE_Ba{I%l1Io&m?ms>|amtFUyoJij%M=tK z9zS&GcX}2x9;6|_4ekQcNgwFxccP9r3a5sU%Gzr?!zmOpIv_N#zw`NpMtk1juadhYt2TW>#{y?X2X z&AVr=-a2;b!r`N*j~+jF`O(u8=PqBo^YHYg8%Ix^pP~#3;UmY+p1FME_WO@_K7KxX z{mwx(^-K339Z*p{sHJ^S-|&#R#UUHpL&hctb@h&R^&a-{IyZan@xqeh>6s_C>^d47 zf7r?8_=G9PYwC{#1f6c{JbmEkoQdh5>d#mkS+U3hlNv@^Z^ha;k{UAlVu_?gp3PMqAZ<@mH&#}kt-Uc7SR_}L3v zcN~j|x_ILB@m1@OHMX8CuR5Hackb-P%ZHEd-+AD|#?9x}ZaCG@bhxy#TU+L;X=!jC>m5y>(tv54MU5vg@thW#r z;`;mZ!Nudd79Yzp*Oglye}BW;*{Pwfjyh{Q{TEjh7iG%u|M>Rl-QzpgcCU<+d-?L# zxzqFJO#Sus-TixKW|o)ceE<5gp)yV3)$=p6zdpa)WS>y6t$=|+?wO~HV@SoVH+Rlv z2?vU>f0*of$;aVSqR^#fQ!cVdZ5DieOpm7{M5C5rv(~!q8?8GoSiirZzGB@SPOq(t zuDY=tY@N{HEiPJ~$5P9pT-TE{`;L#*%;NVy|KD5ir2gGp%jZ6v49MV6!RlVOeF5KM zm@>OsgPtwt!T9k(}K_Pa@Du+_%Uji ze(G*MbbX?ZC5ss+SNvs88K!G9R_o65SXsD~o8?&X>78#pMg3O!B^a$Qv(oqzP{fkv zHAUPg({!g)V_}nYebGv`Mn|FEWnm9rdzW+cJmAUuGjobnnIzLUHsQH96I=V)9jh(l zYA@XH4BF*!Ab8`2Gb*7^#5ivhHvRK^67+)e^QXth{8wpe2^t9clq}Qi4K?ID^sW2? zze&8o#ZLEW3T8(W+Lg^Rb=qGf-@U=hZQZo^X7OYBq{al6w#9dnxA+;b{O>%sB#m>C z@R#x(qW|R8IhBM&_9f|fe&GMCW+i;6I^n>X&T}n7EG};TB?1g)bv`8#_kVCWI8;3R z-&ncer?9LIOW(?!?hB@Z#J7)X zj{d*>=al3m-M|M%r)>FFF8+URYqz=fzr=HS+dR~kN5z}JHPsJcco#E!|2zJ`%+uWr z^JV!&U+OF{)u^f8a%$Ov!-DOBjO60fs~Xe28O~TR-Kbx@xILBOhB0OrGVc}US|;1D`G}kB@_HUd3s;|#G|?jh4D%~A9)IiC zWU!g$T=?g>7=zgFhuvH(><1#l1)>=qy=S$4E_y_~;fA^M+%-%m84vu_w`TBVUC5kp z&@Uja@d?9}jXxBv7&vwQp5$YQV|c`WAhqj!8`DX~7YPgs)(n!Ylb8bn6f7C^%orkA z9`GDZacq3T@PI);j^PYL3cEt%`TZ&E47Lh-j5}O59xzwf#2gT&V8sx@JVlm4Jea|Z zafj2d4eUocPck0hRj_8@o5iq+xuKlFjd6!pl>z&sV-FY-L<1@q3^=C9HdwOOFl}(F z+Q5CRcPHZsO=p{%mB0Soeg5v!Y}4q-^8!T7cd>as>4#Na`14DU~W^&ndkgI zvhkBXYPmYz3;uO*^Swp4T)wJ*dGmDZNh@Qk@8{;M{QQ?a<_`ZkTlb6e1AnP32+_YD z2T>(B6o@j;~XJ9S9yo+(;P0HS;%U{CcxM5Jzm&jmt_r`Br^x|fyH%B zg**yg3}$xf8@?2;XzWpIn0#od2lI<1LXF25G8li9yuU6~b;y#(@fZUW+l0;U6;+!u zrBiqmA{ds&otY`l$CI#b1(U|LBoB-B|C|RB9|pNF+DIJ}dpS>!-*$o_153lV&*8@# z8C@6}`ag&N+47>fhw+an!`si_k0!Dhu?dJUtQL3X>T_0Mm?NO)D$pRyZ8V3OnM2OO z*lh_X1IQu?hOZJ!PbnlbG%~DMk~AfWfst9Gwr^sWO@s2Gr5%hCM>9-8UST-k{djs3 zOA=RZ?o;&pX178Y{gLT8QkI@gSf3O&_Es%ClPPABbU?GAA8WP-oWE;d}3RjNuEDMxAL>KjQ_)1nGt1v)3~lu`LjF zP)-x*W@O=7kUn7zpAp*v;R6R2NLDhKw|WaQ8kl@H>M&)HWWK<$AlFLy@IqcAwgrL* zw!Q4&YcHK($WY60%{RToBB!B;@dfh*V_CsR2N;?dKHM>o6?}AoRWgv_H*1^-!xZ22 z7K;i0cmAknbf~-R!ZrPH@dQEE0*42#3u=<;{ZZe3d&$-5UayUjQ}a=o;>?)B;~?Ddt~TAfc*WlA1M^RBJ*`{(a1rB! z83z{gC)8Bms9>rWj`nOwO|7W_jK**xhBa9i@6>&9azklu;UTO z!vzoiq<;0ypPa^(!sEc*;Qowjl6L!*O}l?>oweMvSUAm)L6+f_w#Pdes|`6n_f7XK z<_4?##3gqwk>zxHudUB4-3fuL1tAXv*$WDex?8(`xLo~sTl9R#TAl~fz4-qy{9ySf z^~RuK0^^$XKUPd-pP!qvOII>MjQIk?f>-Pg!Zp`#R2g(Kwy-8lIlE40b|l09wOcPZ z7IPS_VmQZbbBlp5O;-2FLVhhRo5N~na|7gOJyS<>4Ii*b7f#JfR!}k{Q7_DMh$6<4dfh*Hw&SCF>SW9bh z<_la8DsQ@D&5+?K!!>!oC@}~Al8{n{ z6^C>dG3asGoMJEwTW&k0l6^xEgX~Qng&mp+S1&G-IksT&oozLY&o?zPYOr63buD_n zA(%y0bo-@E#|^qQ8nT)CjT)Y>j(8`Nb0O&HpU19~88z4gV!nni0ccZ{$7^WjJ*3oTExG>w`51lzAUK)@8jq&sijyVRvcX$`_%gUHyz~8=}8Yo7Hz+ zv1lbzf!BkHY!go33A)l<+7ZE%aA~vk<{F`ju(Qrnk{BlcDwp~Bcti4b$F+`qX!`MTO6gTBY@lO<$I%Lumt^f6l7=AD$yb(4m4v^ED zy+DZTL7RIzd&72ZyNP;y=XYNTcVcf4WZx+>b3^SgjnV)*7a*!#+oX-o%JFaA}T73?gtY`uZET*Lg| z_rHmyXfqtSyJ6mGiFU)ZPUa8A|6d)Fz7WOWkRG9)^uVE>!-Lu2cKt`*3`2$`)ul?c zGH$O=2(dl5o%8>w`GT_y4UaFLUbE|zK)c?i6h@2N`w#GDm@_OnTlK-a&0@_OCIQw5 zN&jzi*RyYtVfe62=!wyxh3pPO4V(XR-)*?Y%)sZ*WL3xaP)&iO;nw~Gyb(+c9%|8y zlJ@Ot3hWKH_MhO5U}JEwn#tz!M`n?%CBv0}t$7EE7#J?;edYR4Y$3q>VE3*47kDGY z7!E|0O9&Vi-|%W>2>30&hw(HsL&lchjDq^@(|8!N{x335n90CU_}oO{(US|}j1F(( zf3WF@H>{caH#1?$btZ-tf4lEDEM;ae;XA%HvhmutGt2?M^$VG&2{>Fk_hiYm{`rRu z%y=LCp1x|;2i^!j28TaY`K#SbZu@*nKk$iR->SdUcQT&lQqazPe=Ym-4a+yuEDOGw z*D-T3O_<8(SIib+*>i?5;P>qPjH!$#qOyC7*(5CA%wSsZO}v1)i+RG-ZPh!g8@dlS z$TRHS-y$8L)UamB__cR2ocW%< zh4CWuiYt7x8rI(I_cHuuDY80*amM@enaqpW117UCa;-9B21u6|SW>38& z^^iSbpZ$K`4*3IKah9w~bGwUt-pOox0diac^CR{R6T|gZIV|}X6gS78kgN@6*SLan5y@k!EXQk_~~CkL8AUofX(5Fr{k@Ke*~AjKVJ>< zqgef{1Eq89Sj0*gU;H+{-|&OcMz}umu>6ic+!B*m7rZ}T%lwGlK&)PKE>myc4wd3r z0U*IT=11%WO8><6vI$LX^iT+XEesN^2Z?G4#awB0(a2Nby_Ukb$NJ9wMo<6;socEM zaL6M?b-ndLeuw+^-M_a7J(hRySK*rNut0Z>jwaKE@8wC%kJv8=oqQL&;b&;c|FDd;L+p?jyKO`H{cqx>nan~g0bhI!NIhk&(*s(;>fVukMss59*4nNk)Vt8NoX8p@MY$_ZIA!`4YW-@G> z_tSF0f3{1EGVk8sV^iTsSa^v;I$^olhqrGS0tjN2{@?yP zc-Ia_P9}+!Q_RfNXZ)O|H{;I#vTeLC92lIZPJ6>XAz1m7t}mnS)1Ut)<>fgvG2GJH z8_Hj_a&1lY`ieXDuh~iz8ahuUm9X73brvnYV(#$t<-fjlAcf1cgfFghcH0^fKPxR* z?fdy%jItaH-XC2dE&4n3#G{<0Mjs|`odGprZu$bDy)Vraw_f&mx~!OE=AHj9KrY=n zeb%K#nX*AEw~DqIPW~-l%Phq5;mm1Qp6G(b4GxBmt5=bNWn=dvbTdb2Ok zVp+_)`Y*g50te2V*3Z&s_7MA`=GuD1o|$L9T-5H`?_p+qECsJ3HRqI9*SlVrv32o{ ziVd5e7X5#m@6g0BZJy~ysjt^JKV2QRdb`a_zikuKmuz}fxby!T_pqr_IyZTr9g)~_{V6rz=0vFEJsrFNx*uBpX@gpgcxm#{~xSi3_7up|A~Aevlq*S!u3ZldfK&b z+xkE0%-fp{LX2PT6nfgU`yF^Rf5%Ty?J<8HTM(b%{Q9}lyBSrPBz8zSShk$%i(hT% z%50toGNHt@phzOj;*YSauz4!87fZp5r~|W_|2_Aade%O%ROsDP1_6!-N47c`w0E5T zucEjh&!LmyR$f<3HRGIhTk02OFy(+uDJU%Hm^^9j|6YlroOq@aY*RQCzQowDiF*IH zl2LTXap+|DX2aVd)7H4@;ccyz&$$_sK)Sw~2o$aOUG~rXnp@1|gDLsUN7$xtEO^%2 zFa7_>%K9fC{c{SYFl=O0WzsN@@hy5~`z0#opQW>gQ;q{y!_>FSmQT#%vz>p|KAlrS zdID3zvy2CsOJ*Eikn?hXfX0N3V*&d|I$8^LhPRhYxKH8yPhj7+3X1 zo@bgT^Udp28~>B|Fb zy)?YD^_zbHr;j>g3Ud%c(T}b3rt|f3&946^;9#b}s&LBB;<2jV^7T4@|IeHsZged zC1j>gP5Sh|;%`&7Q9`A3f|-I7gW*FsvrsKNWx?TdxFIr3)gdj*@HC0ehMb+>*$mFKGb&zgG= z=Dt;VkmK-#L1=l^1oagx6DK!pX57rM<^6nx2!EzlFsCE-?EVanVhd>l?gX3vd-9nt zu$^Jr@#Jy;^6)2|DYw8LU}n$0|3{BrSDrU3fh$oK zB-istSfuHJr^Bs=NCvO1-|o-Z?D0aCqveAEyNj4Z_Wz@z#{_t)7wUr0w`3x8YX9Wm$(e^A`v# z@n^ciHs=+?srm77pS;o|livt%6mtYf7tCT-_}>!NbmKO|R>tKP3}^Q%vb@{=P~STJ z^5qbbmWRTB__#7W1e$fO99a>&a|(-C>;?l}Q$t1}AC4b8lUlncn+UczT-FN+^1UIj zEhH-FAA|kb%VBDAhh<;yEuUp+Y*+uVw&+>r)K6IsdzobB*X0*i{16pZ{Hxv=&b&%= z!Df2~r3?0)d!zzZGaO$Yf2-u!cUOxgqK)CqTA~NuzJEB|p;}r_I&6i=f|-pE1qCzm z4rd?c?qxDDu1aa5Q1Gi_(PqUN9+{IcQ9{6A(2Y6aO2sZ1unv>B3^9_Bu{ z*077k;^qH43z>c>UAWJd$GgId;fDN*Bj$?F!a46q1x#kR_b)~1f!c;_2Un&!U-D(Q ze3*9JXTb^AGTs&L4BPDs8fG~BR@sp4V9IphpB#(QgPkYVF>d7uIRAEA<4Zw7jrlBP zyenK7w%a#!IQ&!jknJGLbfx&JaoKL8MlJE5rVCzf{jB>xr~T{=e10al7MU<#UaWNCep~3V$J=LY6)OC#{N;^V&R$9B{fRS_KdxSI>kONf z-QURpYj=HIUh!8hfQ30sCa+$hkMpE!M4k1~PtNx%@9fEFefZX2kty#`Oxe?&oSd2S zneY7L*s?uQ@AtgYk8@9N5Irim$^PZ!4OW5=elQtt*zown@@3+Q#w~aHQ%-O#+H1ez zqy7<*g(6<>+>4KP9lSl?kHtW7$({Z*mow~*PCH}n9rOrpc~E3OYk3%3s}Y0R1shJs zXLTn1DSu`wym>$I*W?W%Iq$gNA8^_jp_9Bs;ZWIyo%+8y&WJNg32qZVc8vM_-;8x} z+Z!Lh?Vlm=+3duL+Lnt4j1`jphsfVYUz2XuPBQWc?#CIms2alx$?zh+@x_Dal6 z|GT63zRF(y2igJe)@$cX{GG<%`QBdO+3YCSW~1--{SuPRzV2k-`d3$;Y3Kj=mrqaM zI4^hM+xALM?V|el&z?o*GC#iZ6>Ux3_}_YG{f>KY{=BNP`0z;H_4ePh{0~1)y^|kY zUsr8xyZeO+f6-MJ>G^lcQ@{Kcu+nUaxfwC}x2e;9p7S$}A}9WK^V2*0;!pOqKQXenH~-G}n4gXwPmH$gvEL~#u#-pk zt^d3B%>Ab~T=}m4{cw1#z!Rez@7JIByI5)Z!ovEZ|2Iry+fv=$xjWl&?EXFZ_r;gX ruR8y@S?~Mqc=nbV26pgy=cKQ^kEglE@mL;f1&MjO`njxgN@xNA)H@Yk literal 0 HcmV?d00001 diff --git a/frontend/public/branding/old/favicon.svg b/frontend/public/branding/old/favicon.svg new file mode 100644 index 0000000000..0fef4393aa --- /dev/null +++ b/frontend/public/branding/old/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 9cfe5380bc..8c93539c6d 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3177,6 +3177,7 @@ "rememberme": "Remember me", "invalid": "Invalid username or password.", "locked": "Your account has been locked.", + "sessionExpired": "Your session has expired. Please sign in again.", "signinTitle": "Please sign in", "ssoSignIn": "Login via Single Sign-on", "oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled", @@ -3681,6 +3682,12 @@ "saveSuccess": "Settings saved successfully", "save": "Save Changes", "restartRequired": "Restart Required", + "loginRequired": "Login mode must be enabled to modify admin settings", + "loginDisabled": { + "title": "Login Mode Required", + "message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.", + "readOnly": "The settings below show example values for reference. Enable login mode to view and edit actual configuration." + }, "restart": { "title": "Restart Required", "message": "Settings have been saved successfully. A server restart is required for the changes to take effect.", @@ -3751,6 +3758,12 @@ "description": "Default producer for PDF metadata" } }, + "logoStyle": { + "label": "Logo Style", + "description": "Choose between the modern minimalist logo or the classic S icon", + "classic": "Classic", + "modern": "Modern" + }, "customPaths": { "label": "Custom Paths", "description": "Configure custom file system paths for pipeline processing and external tools", diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index f9937f775f..e7ddf0f0e0 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -143,16 +143,15 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
{ - if (!isDisabled) { - setActive(item.key); - navigate(`/settings/${item.key}`); - } + // Allow navigation even when disabled - the content inside will be disabled + setActive(item.key); + navigate(`/settings/${item.key}`); }} className={`modal-nav-item ${isMobile ? 'mobile' : ''}`} style={{ background: isActive ? colors.navItemActiveBg : 'transparent', - opacity: isDisabled ? 0.5 : 1, - cursor: isDisabled ? 'not-allowed' : 'pointer', + opacity: isDisabled ? 0.6 : 1, + cursor: 'pointer', }} data-tour={`admin-${item.key}-nav`} > diff --git a/frontend/src/core/components/shared/FirstLoginModal.tsx b/frontend/src/core/components/shared/FirstLoginModal.tsx index fa3e42bae6..7cd034edd7 100644 --- a/frontend/src/core/components/shared/FirstLoginModal.tsx +++ b/frontend/src/core/components/shared/FirstLoginModal.tsx @@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }: setLoading(true); setError(''); - await accountService.changePassword(currentPassword, newPassword); + await accountService.changePasswordOnLogin(currentPassword, newPassword); alert({ alertType: 'success', diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx index f25aaedfcb..9ee11cbd26 100644 --- a/frontend/src/core/components/shared/LandingPage.tsx +++ b/frontend/src/core/components/shared/LandingPage.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { useFileHandler } from '@app/hooks/useFileHandler'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { BASE_PATH } from '@app/constants/app'; +import { useLogoPath } from '@app/hooks/useLogoPath'; const LandingPage = () => { const { addFiles } = useFileHandler(); @@ -14,6 +15,7 @@ const LandingPage = () => { const { t } = useTranslation(); const { openFilesModal } = useFilesModalContext(); const [isUploadHover, setIsUploadHover] = React.useState(false); + const logoPath = useLogoPath(); const handleFileDrop = async (files: File[]) => { await addFiles(files); @@ -72,7 +74,7 @@ const LandingPage = () => { }} > Stirling PDF Logo} + title={t('admin.settings.loginDisabled.title', 'Login Mode Required')} + color="blue" + variant="light" + styles={{ + root: { + borderLeft: '4px solid var(--mantine-color-blue-6)' + } + }} + > + + {t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')} + + + {t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')} + + + ); +} diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index a1f66480b8..36dd4dfcd2 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -2,18 +2,6 @@ import React from 'react'; import { NavKey } from '@app/components/shared/config/types'; import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection'; import GeneralSection from '@app/components/shared/config/configSections/GeneralSection'; -import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection'; -import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection'; -import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection'; -import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection'; -import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection'; -import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection'; -import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection'; -import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection'; -import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection'; -import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection'; -import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection'; -import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection'; export interface ConfigNavItem { key: NavKey; @@ -40,8 +28,8 @@ export interface ConfigColors { } export const createConfigNavSections = ( - isAdmin: boolean = false, - runningEE: boolean = false, + _isAdmin: boolean = false, + _runningEE: boolean = false, _loginEnabled: boolean = false ): ConfigNavSection[] => { const sections: ConfigNavSection[] = [ @@ -64,112 +52,5 @@ export const createConfigNavSections = ( }, ]; - // Add Admin sections if user is admin - if (isAdmin) { - // Configuration - sections.push({ - title: 'Configuration', - items: [ - { - key: 'adminGeneral', - label: 'System Settings', - icon: 'settings-rounded', - component: - }, - { - key: 'adminFeatures', - label: 'Features', - icon: 'extension-rounded', - component: - }, - { - key: 'adminEndpoints', - label: 'Endpoints', - icon: 'api-rounded', - component: - }, - { - key: 'adminDatabase', - label: 'Database', - icon: 'storage-rounded', - component: - }, - { - key: 'adminAdvanced', - label: 'Advanced', - icon: 'tune-rounded', - component: - }, - ], - }); - - // Security & Authentication - sections.push({ - title: 'Security & Authentication', - items: [ - { - key: 'adminSecurity', - label: 'Security', - icon: 'shield-rounded', - component: - }, - { - key: 'adminConnections', - label: 'Connections', - icon: 'link-rounded', - component: - }, - ], - }); - - // Licensing & Analytics - sections.push({ - title: 'Licensing & Analytics', - items: [ - { - key: 'adminPremium', - label: 'Premium', - icon: 'star-rounded', - component: - }, - { - key: 'adminAudit', - label: 'Audit', - icon: 'fact-check-rounded', - component: , - disabled: !runningEE, - disabledTooltip: 'Requires Enterprise license' - }, - { - key: 'adminUsage', - label: 'Usage Analytics', - icon: 'analytics-rounded', - component: , - disabled: !runningEE, - disabledTooltip: 'Requires Enterprise license' - }, - ], - }); - - // Policies & Privacy - sections.push({ - title: 'Policies & Privacy', - items: [ - { - key: 'adminLegal', - label: 'Legal', - icon: 'gavel-rounded', - component: - }, - { - key: 'adminPrivacy', - label: 'Privacy', - icon: 'visibility-rounded', - component: - }, - ], - }); - } - return sections; }; diff --git a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx index 6209fb4b6d..3d07338ac6 100644 --- a/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -10,6 +10,7 @@ interface ProviderCardProps { settings?: Record; onSave?: (settings: Record) => void; onDisconnect?: () => void; + disabled?: boolean; } export default function ProviderCard({ @@ -18,6 +19,7 @@ export default function ProviderCard({ settings = {}, onSave, onDisconnect, + disabled = false, }: ProviderCardProps) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -39,6 +41,7 @@ export default function ProviderCard({ }; const handleFieldChange = (key: string, value: any) => { + if (disabled) return; // Block changes when disabled setLocalSettings((prev) => ({ ...prev, [key]: value })); }; @@ -63,6 +66,7 @@ export default function ProviderCard({ handleFieldChange(field.key, e.target.checked)} + disabled={disabled} />
); @@ -76,6 +80,7 @@ export default function ProviderCard({ placeholder={field.placeholder} value={value} onChange={(e) => handleFieldChange(field.key, e.target.value)} + disabled={disabled} /> ); @@ -88,6 +93,7 @@ export default function ProviderCard({ placeholder={field.placeholder} value={value} onChange={(e) => handleFieldChange(field.key, e.target.value)} + disabled={disabled} /> ); @@ -100,6 +106,7 @@ export default function ProviderCard({ placeholder={field.placeholder} value={value} onChange={(e) => handleFieldChange(field.key, e.target.value)} + disabled={disabled} /> ); } @@ -174,11 +181,12 @@ export default function ProviderCard({ color="red" size="sm" onClick={onDisconnect} + disabled={disabled} > {t('admin.settings.connections.disconnect', 'Disconnect')} )} - diff --git a/frontend/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/src/core/components/tools/FullscreenToolSurface.tsx index 169aab44c5..7ac5d82c97 100644 --- a/frontend/src/core/components/tools/FullscreenToolSurface.tsx +++ b/frontend/src/core/components/tools/FullscreenToolSurface.tsx @@ -8,6 +8,7 @@ import { ToolRegistryEntry } from '@app/data/toolsTaxonomy'; import { ToolId } from '@app/types/toolId'; import { useFocusTrap } from '@app/hooks/useFocusTrap'; import { BASE_PATH } from '@app/constants/app'; +import { useLogoPath } from '@app/hooks/useLogoPath'; import { Tooltip } from '@app/components/shared/Tooltip'; import '@app/components/tools/ToolPanel.css'; import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry'; @@ -51,9 +52,7 @@ const FullscreenToolSurface = ({ useFocusTrap(surfaceRef, !isExiting); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); - const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${ - colorScheme === "dark" ? "Dark" : "Light" - }.svg`; + const brandIconSrc = useLogoPath(); const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${ colorScheme === "dark" ? "White" : "Black" }Text.svg`; diff --git a/frontend/src/core/contexts/AppConfigContext.tsx b/frontend/src/core/contexts/AppConfigContext.tsx index 97cae06712..ad47b415b8 100644 --- a/frontend/src/core/contexts/AppConfigContext.tsx +++ b/frontend/src/core/contexts/AppConfigContext.tsx @@ -19,6 +19,7 @@ export interface AppConfig { serverPort?: number; appNameNavbar?: string; languages?: string[]; + logoStyle?: 'modern' | 'classic'; enableLogin?: boolean; enableEmailInvites?: boolean; isAdmin?: boolean; diff --git a/frontend/src/core/hooks/useLoginRequired.ts b/frontend/src/core/hooks/useLoginRequired.ts new file mode 100644 index 0000000000..ea9af38d90 --- /dev/null +++ b/frontend/src/core/hooks/useLoginRequired.ts @@ -0,0 +1,89 @@ +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { alert } from '@app/components/toast'; +import { useTranslation } from 'react-i18next'; + +/** + * Hook to manage login-required functionality in admin sections + * Provides login state, validation, and alert functionality + */ +export function useLoginRequired() { + const { config } = useAppConfig(); + const { t } = useTranslation(); + const loginEnabled = config?.enableLogin ?? true; + + /** + * Show alert when user tries to modify settings with login disabled + */ + const showLoginRequiredAlert = () => { + alert({ + alertType: 'warning', + title: t('admin.error', 'Error'), + body: t('admin.settings.loginRequired', 'Login mode must be enabled to modify admin settings'), + }); + }; + + /** + * Validate that login is enabled before allowing action + * Returns true if login is enabled, false otherwise (and shows alert) + */ + const validateLoginEnabled = (): boolean => { + if (!loginEnabled) { + showLoginRequiredAlert(); + return false; + } + return true; + }; + + /** + * Wrap an async handler to check login state before executing + */ + const withLoginCheck = Promise>( + handler: T + ): T => { + return (async (...args: any[]) => { + if (!validateLoginEnabled()) { + return; + } + return handler(...args); + }) as T; + }; + + /** + * Get styles for disabled inputs (cursor not-allowed) + */ + const getDisabledStyles = () => { + if (!loginEnabled) { + return { + input: { cursor: 'not-allowed' }, + track: { cursor: 'not-allowed' }, + thumb: { cursor: 'not-allowed' } + }; + } + return undefined; + }; + + /** + * Wrap fetch function to skip API call when login disabled + */ + const withLoginCheckForFetch = Promise>( + fetchHandler: T, + skipWhenDisabled: boolean = true + ): T => { + return (async (...args: any[]) => { + if (!loginEnabled && skipWhenDisabled) { + // Skip fetch when login disabled - component will use default/empty values + return; + } + return fetchHandler(...args); + }) as T; + }; + + return { + loginEnabled, + showLoginRequiredAlert, + validateLoginEnabled, + withLoginCheck, + withLoginCheckForFetch, + getDisabledStyles, + }; +} diff --git a/frontend/src/core/hooks/useLogoPath.ts b/frontend/src/core/hooks/useLogoPath.ts new file mode 100644 index 0000000000..db97f9c08e --- /dev/null +++ b/frontend/src/core/hooks/useLogoPath.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useMantineColorScheme } from '@mantine/core'; +import { BASE_PATH } from '@app/constants/app'; + +/** + * Hook to get the correct logo path based on app config (logo style) and theme (light/dark) + * + * Logo styles: + * - classic: branding/old/favicon.svg (classic S logo - default) + * - modern: StirlingPDFLogoNoText{Light|Dark}.svg (minimalist modern design) + * + * @returns The path to the appropriate logo SVG file + */ +export function useLogoPath(): string { + const { config } = useAppConfig(); + const { colorScheme } = useMantineColorScheme(); + + return useMemo(() => { + const logoStyle = config?.logoStyle || 'classic'; + + if (logoStyle === 'classic') { + // Classic logo (old favicon) - same for both light and dark modes + return `${BASE_PATH}/branding/old/favicon.svg`; + } + + // Modern logo - different for light and dark modes + const themeSuffix = colorScheme === 'dark' ? 'Dark' : 'Light'; + return `${BASE_PATH}/branding/StirlingPDFLogoNoText${themeSuffix}.svg`; + }, [config?.logoStyle, colorScheme]); +} diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 6df688e55d..2731ad1004 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -8,6 +8,7 @@ import { BASE_PATH } from "@app/constants/app"; import { useBaseUrl } from "@app/hooks/useBaseUrl"; import { useIsMobile } from "@app/hooks/useIsMobile"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useLogoPath } from "@app/hooks/useLogoPath"; import AppsIcon from '@mui/icons-material/AppsRounded'; import ToolPanel from "@app/components/tools/ToolPanel"; @@ -60,9 +61,7 @@ export default function HomePage() { }, [config]); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); - const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${ - colorScheme === "dark" ? "Dark" : "Light" - }.svg`; + const brandIconSrc = useLogoPath(); const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${ colorScheme === "dark" ? "White" : "Black" }Text.svg`; diff --git a/frontend/src/core/services/accountService.ts b/frontend/src/core/services/accountService.ts index f546ed8bf5..0b2aa52e07 100644 --- a/frontend/src/core/services/accountService.ts +++ b/frontend/src/core/services/accountService.ts @@ -31,4 +31,14 @@ export const accountService = { formData.append('newPassword', newPassword); await apiClient.post('/api/v1/user/change-password', formData); }, + + /** + * Change user password on first login (resets firstLogin flag) + */ + async changePasswordOnLogin(currentPassword: string, newPassword: string): Promise { + const formData = new FormData(); + formData.append('currentPassword', currentPassword); + formData.append('newPassword', newPassword); + await apiClient.post('/api/v1/user/change-password-on-login', formData); + }, }; diff --git a/frontend/src/core/services/httpErrorHandler.ts b/frontend/src/core/services/httpErrorHandler.ts index fa5160adf2..53e04516a4 100644 --- a/frontend/src/core/services/httpErrorHandler.ts +++ b/frontend/src/core/services/httpErrorHandler.ts @@ -92,6 +92,32 @@ export async function handleHttpError(error: any): Promise { if (error?.config?.suppressErrorToast === true) { return false; // Don't show global toast, but continue rejection } + + // Handle 401 authentication errors + const status: number | undefined = error?.response?.status; + if (status === 401) { + const pathname = window.location.pathname; + + // Check if we're already on an auth page + const isAuthPage = pathname.includes('/login') || + pathname.includes('/signup') || + pathname.includes('/auth/') || + pathname.includes('/invite/'); + + // If not on auth page, redirect to login with expired session message + if (!isAuthPage) { + console.debug('[httpErrorHandler] 401 detected, redirecting to login'); + // Store the current location so we can redirect back after login + const currentLocation = window.location.pathname + window.location.search; + // Redirect to login with state + window.location.href = `/login?expired=true&from=${encodeURIComponent(currentLocation)}`; + return true; // Suppress toast since we're redirecting + } + + // On auth pages, suppress the toast (user is already trying to authenticate) + console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname); + return true; + } // Compute title/body (friendly) from the error object const { title, body } = extractAxiosErrorMessage(error); @@ -112,7 +138,6 @@ export async function handleHttpError(error: any): Promise { // 2) Generic-vs-special dedupe by endpoint const url: string | undefined = error?.config?.url; - const status: number | undefined = error?.response?.status; const now = Date.now(); const isSpecial = status === 422 || diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 5d3e2d4dcb..04fb559576 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -251,7 +251,7 @@ class SpringAuthClient { * This redirects to the Spring OAuth2 authorization endpoint */ async signInWithOAuth(params: { - provider: 'github' | 'google' | 'apple' | 'azure'; + provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc'; options?: { redirectTo?: string; queryParams?: Record }; }): Promise<{ error: AuthError | null }> { try { diff --git a/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx b/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx index 00b5dac984..f7157a43e5 100644 --- a/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx +++ b/frontend/src/proprietary/components/shared/LoginRightCarousel.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { BASE_PATH } from '@app/constants/app'; type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number } -export default function LoginRightCarousel({ +function LoginRightCarousel({ imageSlides = [], showBackground = true, initialSeconds = 5, @@ -157,3 +157,5 @@ export default function LoginRightCarousel({
); } + +export default memo(LoginRightCarousel); diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx index 9be7640cc5..bc63e979ad 100644 --- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx @@ -2,41 +2,181 @@ import React from 'react'; import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections'; import PeopleSection from '@app/components/shared/config/configSections/PeopleSection'; import TeamsSection from '@app/components/shared/config/configSections/TeamsSection'; +import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection'; +import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection'; +import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection'; +import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection'; +import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection'; +import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection'; +import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection'; +import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection'; +import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection'; +import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection'; +import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection'; +import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection'; import ApiKeys from '@app/components/shared/config/configSections/ApiKeys'; /** - * Proprietary extension of createConfigNavSections that adds workspace sections + * Proprietary extension of createConfigNavSections that adds all admin and workspace sections */ export const createConfigNavSections = ( isAdmin: boolean = false, runningEE: boolean = false, loginEnabled: boolean = false ): ConfigNavSection[] => { - // Get the core sections - const sections = createCoreConfigNavSections(isAdmin, runningEE); + // Get the core sections (just Preferences) + const sections = createCoreConfigNavSections(isAdmin, runningEE, loginEnabled); - // Add Workspace section if user is admin - if (isAdmin) { - const workspaceSection: ConfigNavSection = { + // Add Admin sections if user is admin OR if login is disabled (but mark as disabled) + if (isAdmin || !loginEnabled) { + const requiresLogin = !loginEnabled; + + // Workspace + sections.push({ title: 'Workspace', items: [ { key: 'people', label: 'People', icon: 'group-rounded', - component: + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined }, { key: 'teams', label: 'Teams', icon: 'groups-rounded', - component: + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined }, ], - }; + }); - // Insert workspace section after Preferences (at index 1) - sections.splice(1, 0, workspaceSection); + // Configuration + sections.push({ + title: 'Configuration', + items: [ + { + key: 'adminGeneral', + label: 'System Settings', + icon: 'settings-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + { + key: 'adminFeatures', + label: 'Features', + icon: 'extension-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + { + key: 'adminEndpoints', + label: 'Endpoints', + icon: 'api-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + { + key: 'adminDatabase', + label: 'Database', + icon: 'storage-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + { + key: 'adminAdvanced', + label: 'Advanced', + icon: 'tune-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + ], + }); + + // Security & Authentication + sections.push({ + title: 'Security & Authentication', + items: [ + { + key: 'adminSecurity', + label: 'Security', + icon: 'shield-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + { + key: 'adminConnections', + label: 'Connections', + icon: 'link-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + ], + }); + + // Licensing & Analytics + sections.push({ + title: 'Licensing & Analytics', + items: [ + { + key: 'adminPremium', + label: 'Premium', + icon: 'star-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + { + key: 'adminAudit', + label: 'Audit', + icon: 'fact-check-rounded', + component: , + disabled: !runningEE || requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license' + }, + { + key: 'adminUsage', + label: 'Usage Analytics', + icon: 'analytics-rounded', + component: , + disabled: !runningEE || requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : 'Requires Enterprise license' + }, + ], + }); + + // Policies & Privacy + sections.push({ + title: 'Policies & Privacy', + items: [ + { + key: 'adminLegal', + label: 'Legal', + icon: 'gavel-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + { + key: 'adminPrivacy', + label: 'Privacy', + icon: 'visibility-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined + }, + ], + }); } // Add Developer section if login is enabled diff --git a/frontend/src/core/components/shared/config/configSections/AdminAdvancedSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx similarity index 91% rename from frontend/src/core/components/shared/config/configSections/AdminAdvancedSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx index ffb9c8d0cd..15ee65b3dd 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminAdvancedSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx @@ -7,6 +7,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; import apiClient from '@app/services/apiClient'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface AdvancedSettingsData { enableAlphaFunctionality?: boolean; @@ -55,6 +57,7 @@ interface AdvancedSettingsData { export default function AdminAdvancedSection() { const { t } = useTranslation(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { settings, @@ -165,10 +168,15 @@ export default function AdminAdvancedSection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled]); const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } try { await saveSettings(); showRestartModal(); @@ -181,7 +189,9 @@ export default function AdminAdvancedSection() { } }; - if (loading) { + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return ( @@ -191,6 +201,7 @@ export default function AdminAdvancedSection() { return ( +
{t('admin.settings.advanced.title', 'Advanced')} @@ -213,7 +224,12 @@ export default function AdminAdvancedSection() { setSettings({ ...settings, enableAlphaFunctionality: e.target.checked })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, enableAlphaFunctionality: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -229,7 +245,12 @@ export default function AdminAdvancedSection() { setSettings({ ...settings, enableUrlToPDF: e.target.checked })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, enableUrlToPDF: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -245,7 +266,12 @@ export default function AdminAdvancedSection() { setSettings({ ...settings, disableSanitize: e.target.checked })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, disableSanitize: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -271,6 +297,7 @@ export default function AdminAdvancedSection() { onChange={(value) => setSettings({ ...settings, maxDPI: Number(value) })} min={0} max={3000} + disabled={!loginEnabled} />
@@ -286,6 +313,7 @@ export default function AdminAdvancedSection() { value={settings.tessdataDir || ''} onChange={(e) => setSettings({ ...settings, tessdataDir: e.target.value })} placeholder="/usr/share/tessdata" + disabled={!loginEnabled} />
@@ -311,6 +339,7 @@ export default function AdminAdvancedSection() { tempFileManagement: { ...settings.tempFileManagement, baseTmpDir: e.target.value } })} placeholder="Default: java.io.tmpdir/stirling-pdf" + disabled={!loginEnabled} />
@@ -324,6 +353,7 @@ export default function AdminAdvancedSection() { tempFileManagement: { ...settings.tempFileManagement, libreofficeDir: e.target.value } })} placeholder="Default: baseTmpDir/libreoffice" + disabled={!loginEnabled} />
@@ -337,6 +367,7 @@ export default function AdminAdvancedSection() { tempFileManagement: { ...settings.tempFileManagement, systemTempDir: e.target.value } })} placeholder="System temp directory path" + disabled={!loginEnabled} />
@@ -350,6 +381,7 @@ export default function AdminAdvancedSection() { tempFileManagement: { ...settings.tempFileManagement, prefix: e.target.value } })} placeholder="stirling-pdf-" + disabled={!loginEnabled} />
@@ -364,6 +396,7 @@ export default function AdminAdvancedSection() { })} min={1} max={720} + disabled={!loginEnabled} />
@@ -378,6 +411,7 @@ export default function AdminAdvancedSection() { })} min={1} max={1440} + disabled={!loginEnabled} />
@@ -391,10 +425,15 @@ export default function AdminAdvancedSection() { setSettings({ - ...settings, - tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked } - })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ + ...settings, + tempFileManagement: { ...settings.tempFileManagement, startupCleanup: e.target.checked } + }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -410,10 +449,15 @@ export default function AdminAdvancedSection() { setSettings({ - ...settings, - tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked } - })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ + ...settings, + tempFileManagement: { ...settings.tempFileManagement, cleanupSystemTemp: e.target.checked } + }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -448,6 +492,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -485,6 +531,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -522,6 +570,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -559,6 +609,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -596,6 +648,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -633,6 +687,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -670,6 +726,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -707,6 +765,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -744,6 +804,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -781,6 +843,7 @@ export default function AdminAdvancedSection() { })} min={1} max={100} + disabled={!loginEnabled} /> @@ -805,7 +869,7 @@ export default function AdminAdvancedSection() { {/* Save Button */} - diff --git a/frontend/src/core/components/shared/config/configSections/AdminAuditSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx similarity index 72% rename from frontend/src/core/components/shared/config/configSections/AdminAuditSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx index 23152bdd84..c7142b3d27 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminAuditSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx @@ -6,9 +6,12 @@ import AuditSystemStatus from '@app/components/shared/config/configSections/audi import AuditChartsSection from '@app/components/shared/config/configSections/audit/AuditChartsSection'; import AuditEventsTable from '@app/components/shared/config/configSections/audit/AuditEventsTable'; import AuditExportSection from '@app/components/shared/config/configSections/audit/AuditExportSection'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; const AdminAuditSection: React.FC = () => { const { t } = useTranslation(); + const { loginEnabled } = useLoginRequired(); const [systemStatus, setSystemStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -27,10 +30,24 @@ const AdminAuditSection: React.FC = () => { } }; - fetchSystemStatus(); - }, []); + if (loginEnabled) { + fetchSystemStatus(); + } else { + // Provide example audit system status when login is disabled + setSystemStatus({ + enabled: true, + level: 'INFO', + retentionDays: 90, + totalEvents: 1234, + }); + setLoading(false); + } + }, [loginEnabled]); - if (loading) { + // Override loading state when login is disabled + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return (
@@ -56,32 +73,33 @@ const AdminAuditSection: React.FC = () => { return ( + {systemStatus.enabled ? ( - + {t('audit.tabs.dashboard', 'Dashboard')} - + {t('audit.tabs.events', 'Audit Events')} - + {t('audit.tabs.export', 'Export')} - + - + - + ) : ( diff --git a/frontend/src/core/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx similarity index 85% rename from frontend/src/core/components/shared/config/configSections/AdminConnectionsSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index ab3dc7dc0a..bbb19380a5 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -12,6 +12,8 @@ import { Provider, } from '@app/components/shared/config/configSections/providerDefinitions'; import apiClient from '@app/services/apiClient'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface ConnectionsSettingsData { oauth2?: { @@ -45,15 +47,10 @@ interface ConnectionsSettingsData { export default function AdminConnectionsSection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - const { - settings, - setSettings, - loading, - fetchSettings, - isFieldPending, - } = useAdminSettings({ + const adminSettings = useAdminSettings({ sectionName: 'connections', fetchTransformer: async () => { // Fetch security settings (oauth2, saml2) @@ -106,57 +103,75 @@ export default function AdminConnectionsSection() { } }); + const { + settings, + setSettings, + loading, + fetchSettings, + isFieldPending, + } = adminSettings; + useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled, fetchSettings]); + + // Override loading state when login is disabled + const actualLoading = loginEnabled ? loading : false; const isProviderConfigured = (provider: Provider): boolean => { if (provider.id === 'saml2') { - return settings.saml2?.enabled === true; + return settings?.saml2?.enabled === true; } if (provider.id === 'smtp') { - return settings.mail?.enabled === true; + return settings?.mail?.enabled === true; } if (provider.id === 'oauth2-generic') { - return settings.oauth2?.enabled === true; + return settings?.oauth2?.enabled === true; } // Check if specific OAuth2 provider is configured (has clientId) - const providerSettings = settings.oauth2?.client?.[provider.id]; + const providerSettings = settings?.oauth2?.client?.[provider.id]; return !!(providerSettings?.clientId); }; const getProviderSettings = (provider: Provider): Record => { if (provider.id === 'saml2') { - return settings.saml2 || {}; + return settings?.saml2 || {}; } if (provider.id === 'smtp') { - return settings.mail || {}; + return settings?.mail || {}; } if (provider.id === 'oauth2-generic') { // Generic OAuth2 settings are at the root oauth2 level return { - enabled: settings.oauth2?.enabled, - provider: settings.oauth2?.provider, - issuer: settings.oauth2?.issuer, - clientId: settings.oauth2?.clientId, - clientSecret: settings.oauth2?.clientSecret, - scopes: settings.oauth2?.scopes, - useAsUsername: settings.oauth2?.useAsUsername, - autoCreateUser: settings.oauth2?.autoCreateUser, - blockRegistration: settings.oauth2?.blockRegistration, + enabled: settings?.oauth2?.enabled, + provider: settings?.oauth2?.provider, + issuer: settings?.oauth2?.issuer, + clientId: settings?.oauth2?.clientId, + clientSecret: settings?.oauth2?.clientSecret, + scopes: settings?.oauth2?.scopes, + useAsUsername: settings?.oauth2?.useAsUsername, + autoCreateUser: settings?.oauth2?.autoCreateUser, + blockRegistration: settings?.oauth2?.blockRegistration, }; } // Specific OAuth2 provider settings - return settings.oauth2?.client?.[provider.id] || {}; + return settings?.oauth2?.client?.[provider.id] || {}; }; const handleProviderSave = async (provider: Provider, providerSettings: Record) => { + // Block save if login is disabled + if (!validateLoginEnabled()) { + return; + } + try { if (provider.id === 'smtp') { // Mail settings use a different endpoint @@ -218,7 +233,12 @@ export default function AdminConnectionsSection() { }; const handleProviderDisconnect = async (provider: Provider) => { - try { + // Block disconnect if login is disabled + if (!validateLoginEnabled()) { + return; + } + + try{ if (provider.id === 'smtp') { // Mail settings use a different endpoint const response = await apiClient.put('/api/v1/admin/settings/section/mail', { enabled: false }); @@ -271,7 +291,7 @@ export default function AdminConnectionsSection() { } }; - if (loading) { + if (actualLoading) { return ( @@ -280,9 +300,14 @@ export default function AdminConnectionsSection() { } const handleSSOAutoLoginSave = async () => { + // Block save if login is disabled + if (!validateLoginEnabled()) { + return; + } + try { const deltaSettings = { - 'premium.proFeatures.ssoAutoLogin': settings.ssoAutoLogin + 'premium.proFeatures.ssoAutoLogin': settings?.ssoAutoLogin }; const response = await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings }); @@ -311,6 +336,8 @@ export default function AdminConnectionsSection() { return ( + + {/* Header */}
@@ -341,11 +368,14 @@ export default function AdminConnectionsSection() {
{ + if (!loginEnabled) return; // Block change when login disabled setSettings({ ...settings, ssoAutoLogin: e.target.checked }); handleSSOAutoLoginSave(); }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -369,6 +399,7 @@ export default function AdminConnectionsSection() { settings={getProviderSettings(provider)} onSave={(providerSettings) => handleProviderSave(provider, providerSettings)} onDisconnect={() => handleProviderDisconnect(provider)} + disabled={!loginEnabled} /> ))}
@@ -392,6 +423,7 @@ export default function AdminConnectionsSection() { provider={provider} isConfigured={false} onSave={(providerSettings) => handleProviderSave(provider, providerSettings)} + disabled={!loginEnabled} /> ))}
diff --git a/frontend/src/core/components/shared/config/configSections/AdminDatabaseSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx similarity index 85% rename from frontend/src/core/components/shared/config/configSections/AdminDatabaseSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx index a289537815..fdc4768bb2 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminDatabaseSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminDatabaseSection.tsx @@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; import apiClient from '@app/services/apiClient'; interface DatabaseSettingsData { @@ -21,6 +23,7 @@ interface DatabaseSettingsData { export default function AdminDatabaseSection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -78,10 +81,16 @@ export default function AdminDatabaseSection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled, fetchSettings]); const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } + try { await saveSettings(); showRestartModal(); @@ -94,7 +103,10 @@ export default function AdminDatabaseSection() { } }; - if (loading) { + // Override loading state when login is disabled + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return ( @@ -104,6 +116,8 @@ export default function AdminDatabaseSection() { return ( + +
@@ -130,14 +144,19 @@ export default function AdminDatabaseSection() {
setSettings({ ...settings, enableCustomDatabase: e.target.checked })} + checked={settings?.enableCustomDatabase || false} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, enableCustomDatabase: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} />
- {settings.enableCustomDatabase && ( + {settings?.enableCustomDatabase && ( <>
} description={t('admin.settings.database.customUrl.description', 'Full JDBC connection string (e.g., jdbc:postgresql://localhost:5432/postgres). If provided, individual connection settings below are not used.')} - value={settings.customDatabaseUrl || ''} + value={settings?.customDatabaseUrl || ''} onChange={(e) => setSettings({ ...settings, customDatabaseUrl: e.target.value })} placeholder="jdbc:postgresql://localhost:5432/postgres" + disabled={!loginEnabled} />
@@ -163,7 +183,7 @@ export default function AdminDatabaseSection() { } description={t('admin.settings.database.type.description', 'Type of database (not used if custom URL is provided)')} - value={settings.type || 'postgresql'} + value={settings?.type || 'postgresql'} onChange={(value) => setSettings({ ...settings, type: value || 'postgresql' })} data={[ { value: 'postgresql', label: 'PostgreSQL' }, @@ -171,6 +191,7 @@ export default function AdminDatabaseSection() { { value: 'mysql', label: 'MySQL' }, { value: 'mariadb', label: 'MariaDB' } ]} + disabled={!loginEnabled} />
@@ -183,9 +204,10 @@ export default function AdminDatabaseSection() { } description={t('admin.settings.database.hostName.description', 'Database server hostname (not used if custom URL is provided)')} - value={settings.hostName || ''} + value={settings?.hostName || ''} onChange={(e) => setSettings({ ...settings, hostName: e.target.value })} placeholder="localhost" + disabled={!loginEnabled} />
@@ -198,10 +220,11 @@ export default function AdminDatabaseSection() { } description={t('admin.settings.database.port.description', 'Database server port (not used if custom URL is provided)')} - value={settings.port || 5432} + value={settings?.port || 5432} onChange={(value) => setSettings({ ...settings, port: Number(value) })} min={1} max={65535} + disabled={!loginEnabled} />
@@ -214,9 +237,10 @@ export default function AdminDatabaseSection() { } description={t('admin.settings.database.name.description', 'Name of the database (not used if custom URL is provided)')} - value={settings.name || ''} + value={settings?.name || ''} onChange={(e) => setSettings({ ...settings, name: e.target.value })} placeholder="postgres" + disabled={!loginEnabled} />
@@ -229,9 +253,10 @@ export default function AdminDatabaseSection() { } description={t('admin.settings.database.username.description', 'Database authentication username')} - value={settings.username || ''} + value={settings?.username || ''} onChange={(e) => setSettings({ ...settings, username: e.target.value })} placeholder="postgres" + disabled={!loginEnabled} />
@@ -244,9 +269,10 @@ export default function AdminDatabaseSection() { } description={t('admin.settings.database.password.description', 'Database authentication password')} - value={settings.password || ''} + value={settings?.password || ''} onChange={(e) => setSettings({ ...settings, password: e.target.value })} placeholder="••••••••" + disabled={!loginEnabled} />
@@ -256,7 +282,7 @@ export default function AdminDatabaseSection() { {/* Save Button */} - diff --git a/frontend/src/core/components/shared/config/configSections/AdminEndpointsSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx similarity index 82% rename from frontend/src/core/components/shared/config/configSections/AdminEndpointsSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx index 56e51381ba..8bcf305bbb 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminEndpointsSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminEndpointsSection.tsx @@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface EndpointsSettingsData { toRemove?: string[]; @@ -14,6 +16,7 @@ interface EndpointsSettingsData { export default function AdminEndpointsSection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -29,10 +32,16 @@ export default function AdminEndpointsSection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled, fetchSettings]); const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } + try { await saveSettings(); showRestartModal(); @@ -45,7 +54,10 @@ export default function AdminEndpointsSection() { } }; - if (loading) { + // Override loading state when login is disabled + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return ( @@ -102,6 +114,8 @@ export default function AdminEndpointsSection() { return ( + +
{t('admin.settings.endpoints.title', 'API Endpoints')} @@ -123,12 +137,16 @@ export default function AdminEndpointsSection() { } description={t('admin.settings.endpoints.toRemove.description', 'Select individual endpoints to disable')} value={settings.toRemove || []} - onChange={(value) => setSettings({ ...settings, toRemove: value })} + onChange={(value) => { + if (!loginEnabled) return; + setSettings({ ...settings, toRemove: value }); + }} data={commonEndpoints.map(endpoint => ({ value: endpoint, label: endpoint }))} searchable clearable placeholder="Select endpoints to disable" comboboxProps={{ zIndex: 1400 }} + disabled={!loginEnabled} />
@@ -142,12 +160,16 @@ export default function AdminEndpointsSection() { } description={t('admin.settings.endpoints.groupsToRemove.description', 'Select endpoint groups to disable')} value={settings.groupsToRemove || []} - onChange={(value) => setSettings({ ...settings, groupsToRemove: value })} + onChange={(value) => { + if (!loginEnabled) return; + setSettings({ ...settings, groupsToRemove: value }); + }} data={commonGroups.map(group => ({ value: group, label: group }))} searchable clearable placeholder="Select groups to disable" comboboxProps={{ zIndex: 1400 }} + disabled={!loginEnabled} /> @@ -160,7 +182,7 @@ export default function AdminEndpointsSection() { - diff --git a/frontend/src/core/components/shared/config/configSections/AdminFeaturesSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx similarity index 83% rename from frontend/src/core/components/shared/config/configSections/AdminFeaturesSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx index 72e19be1ac..20ec8ed737 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminFeaturesSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminFeaturesSection.tsx @@ -7,6 +7,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; import apiClient from '@app/services/apiClient'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface FeaturesSettingsData { serverCertificate?: { @@ -19,6 +21,7 @@ interface FeaturesSettingsData { export default function AdminFeaturesSection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -69,10 +72,15 @@ export default function AdminFeaturesSection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled]); const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } try { await saveSettings(); showRestartModal(); @@ -85,7 +93,9 @@ export default function AdminFeaturesSection() { } }; - if (loading) { + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return ( @@ -95,6 +105,7 @@ export default function AdminFeaturesSection() { return ( +
{t('admin.settings.features.title', 'Features')} @@ -124,10 +135,15 @@ export default function AdminFeaturesSection() { setSettings({ - ...settings, - serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked } - })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ + ...settings, + serverCertificate: { ...settings.serverCertificate, enabled: e.target.checked } + }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -148,6 +164,7 @@ export default function AdminFeaturesSection() { serverCertificate: { ...settings.serverCertificate, organizationName: e.target.value } })} placeholder="Stirling-PDF" + disabled={!loginEnabled} />
@@ -167,6 +184,7 @@ export default function AdminFeaturesSection() { })} min={1} max={3650} + disabled={!loginEnabled} /> @@ -180,10 +198,15 @@ export default function AdminFeaturesSection() { setSettings({ - ...settings, - serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked } - })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ + ...settings, + serverCertificate: { ...settings.serverCertificate, regenerateOnStartup: e.target.checked } + }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -193,7 +216,7 @@ export default function AdminFeaturesSection() { {/* Save Button */} - diff --git a/frontend/src/core/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx similarity index 81% rename from frontend/src/core/components/shared/config/configSections/AdminGeneralSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx index 4f354a8a95..3e2b445a63 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminGeneralSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx @@ -1,17 +1,20 @@ import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge } from '@mantine/core'; +import { TextInput, Switch, Button, Stack, Paper, Text, Loader, Group, MultiSelect, Badge, SegmentedControl } from '@mantine/core'; import { alert } from '@app/components/toast'; import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; import apiClient from '@app/services/apiClient'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface GeneralSettingsData { ui: { appNameNavbar?: string; languages?: string[]; + logoStyle?: 'modern' | 'classic'; }; system: { defaultLocale?: string; @@ -40,6 +43,7 @@ interface GeneralSettingsData { export default function AdminGeneralSection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -108,14 +112,15 @@ export default function AdminGeneralSection() { saveTransformer: (settings) => { const deltaSettings: Record = { // UI settings - 'ui.appNameNavbar': settings.ui.appNameNavbar, - 'ui.languages': settings.ui.languages, + 'ui.appNameNavbar': settings.ui?.appNameNavbar, + 'ui.languages': settings.ui?.languages, + 'ui.logoStyle': settings.ui?.logoStyle, // System settings - 'system.defaultLocale': settings.system.defaultLocale, - 'system.showUpdate': settings.system.showUpdate, - 'system.showUpdateOnlyAdmin': settings.system.showUpdateOnlyAdmin, - 'system.customHTMLFiles': settings.system.customHTMLFiles, - 'system.fileUploadLimit': settings.system.fileUploadLimit, + 'system.defaultLocale': settings.system?.defaultLocale, + 'system.showUpdate': settings.system?.showUpdate, + 'system.showUpdateOnlyAdmin': settings.system?.showUpdateOnlyAdmin, + 'system.customHTMLFiles': settings.system?.customHTMLFiles, + 'system.fileUploadLimit': settings.system?.fileUploadLimit, // Premium custom metadata 'premium.proFeatures.customMetadata.autoUpdateMetadata': settings.customMetadata?.autoUpdateMetadata, 'premium.proFeatures.customMetadata.author': settings.customMetadata?.author, @@ -124,10 +129,10 @@ export default function AdminGeneralSection() { }; if (settings.customPaths) { - deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths.pipeline?.watchedFoldersDir; - deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths.pipeline?.finishedFoldersDir; - deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths.operations?.weasyprint; - deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths.operations?.unoconvert; + deltaSettings['system.customPaths.pipeline.watchedFoldersDir'] = settings.customPaths?.pipeline?.watchedFoldersDir; + deltaSettings['system.customPaths.pipeline.finishedFoldersDir'] = settings.customPaths?.pipeline?.finishedFoldersDir; + deltaSettings['system.customPaths.operations.weasyprint'] = settings.customPaths?.operations?.weasyprint; + deltaSettings['system.customPaths.operations.unoconvert'] = settings.customPaths?.operations?.unoconvert; } return { @@ -138,10 +143,21 @@ export default function AdminGeneralSection() { }); useEffect(() => { - fetchSettings(); - }, []); + // Only fetch real settings if login is enabled + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled, fetchSettings]); + + // Override loading state when login is disabled + const actualLoading = loginEnabled ? loading : false; const handleSave = async () => { + // Block save if login is disabled + if (!validateLoginEnabled()) { + return; + } + try { await saveSettings(); showRestartModal(); @@ -154,7 +170,7 @@ export default function AdminGeneralSection() { } }; - if (loading) { + if (actualLoading) { return ( @@ -164,6 +180,8 @@ export default function AdminGeneralSection() { return ( + +
{t('admin.settings.general.title', 'System Settings')} @@ -185,9 +203,55 @@ export default function AdminGeneralSection() { } description={t('admin.settings.general.appNameNavbar.description', 'The name displayed in the navigation bar')} - value={settings.ui.appNameNavbar || ''} + value={settings.ui?.appNameNavbar || ''} onChange={(e) => setSettings({ ...settings, ui: { ...settings.ui, appNameNavbar: e.target.value } })} placeholder="Stirling PDF" + disabled={!loginEnabled} + /> +
+ +
+ + + {t('admin.settings.general.logoStyle.label', 'Logo Style')} + + + + + {t('admin.settings.general.logoStyle.description', 'Choose between the modern minimalist logo or the classic S icon')} + + setSettings({ ...settings, ui: { ...settings.ui, logoStyle: value as 'modern' | 'classic' } })} + data={[ + { + value: 'classic', + label: ( +
+ Classic logo + {t('admin.settings.general.logoStyle.classic', 'Classic')} +
+ ) + }, + { + value: 'modern', + label: ( +
+ Modern logo + {t('admin.settings.general.logoStyle.modern', 'Modern')} +
+ ) + }, + ]} + disabled={!loginEnabled} />
@@ -200,7 +264,7 @@ export default function AdminGeneralSection() { } description={t('admin.settings.general.languages.description', 'Limit which languages are available (empty = all languages)')} - value={settings.ui.languages || []} + value={settings.ui?.languages || []} onChange={(value) => setSettings({ ...settings, ui: { ...settings.ui, languages: value } })} data={[ { value: 'de_DE', label: 'Deutsch' }, @@ -218,6 +282,7 @@ export default function AdminGeneralSection() { clearable placeholder="Select languages" comboboxProps={{ zIndex: 1400 }} + disabled={!loginEnabled} /> @@ -230,9 +295,10 @@ export default function AdminGeneralSection() { } description={t('admin.settings.general.defaultLocale.description', 'The default language for new users (e.g., en_US, es_ES)')} - value={settings.system.defaultLocale || ''} + value={ settings.system?.defaultLocale || ''} onChange={(e) => setSettings({ ...settings, system: { ...settings.system, defaultLocale: e.target.value } })} placeholder="en_US" + disabled={!loginEnabled} /> @@ -245,9 +311,10 @@ export default function AdminGeneralSection() { } description={t('admin.settings.general.fileUploadLimit.description', 'Maximum file upload size (e.g., 100MB, 1GB)')} - value={settings.system.fileUploadLimit || ''} + value={ settings.system?.fileUploadLimit || ''} onChange={(e) => setSettings({ ...settings, system: { ...settings.system, fileUploadLimit: e.target.value } })} placeholder="100MB" + disabled={!loginEnabled} /> @@ -260,8 +327,9 @@ export default function AdminGeneralSection() { setSettings({ ...settings, system: { ...settings.system, showUpdate: e.target.checked } })} + disabled={!loginEnabled} /> @@ -276,8 +344,9 @@ export default function AdminGeneralSection() { setSettings({ ...settings, system: { ...settings.system, showUpdateOnlyAdmin: e.target.checked } })} + disabled={!loginEnabled} /> @@ -292,8 +361,9 @@ export default function AdminGeneralSection() { setSettings({ ...settings, system: { ...settings.system, customHTMLFiles: e.target.checked } })} + disabled={!loginEnabled} /> @@ -326,6 +396,7 @@ export default function AdminGeneralSection() { autoUpdateMetadata: e.target.checked } })} + disabled={!loginEnabled} /> @@ -349,6 +420,7 @@ export default function AdminGeneralSection() { } })} placeholder="username" + disabled={!loginEnabled} /> @@ -370,6 +442,7 @@ export default function AdminGeneralSection() { } })} placeholder="Stirling-PDF" + disabled={!loginEnabled} /> @@ -391,6 +464,7 @@ export default function AdminGeneralSection() { } })} placeholder="Stirling-PDF" + disabled={!loginEnabled} />
@@ -429,6 +503,7 @@ export default function AdminGeneralSection() { } })} placeholder="/pipeline/watchedFolders" + disabled={!loginEnabled} /> @@ -453,6 +528,7 @@ export default function AdminGeneralSection() { } })} placeholder="/pipeline/finishedFolders" + disabled={!loginEnabled} /> @@ -479,6 +555,7 @@ export default function AdminGeneralSection() { } })} placeholder="/opt/venv/bin/weasyprint" + disabled={!loginEnabled} /> @@ -503,6 +580,7 @@ export default function AdminGeneralSection() { } })} placeholder="/opt/venv/bin/unoconvert" + disabled={!loginEnabled} />
@@ -510,7 +588,7 @@ export default function AdminGeneralSection() { {/* Save Button */} - diff --git a/frontend/src/core/components/shared/config/configSections/AdminLegalSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx similarity index 89% rename from frontend/src/core/components/shared/config/configSections/AdminLegalSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx index f47e52a07c..c927547ae1 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminLegalSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminLegalSection.tsx @@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface LegalSettingsData { termsAndConditions?: string; @@ -18,6 +20,7 @@ interface LegalSettingsData { export default function AdminLegalSection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -33,10 +36,15 @@ export default function AdminLegalSection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled]); const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } try { await saveSettings(); showRestartModal(); @@ -49,7 +57,9 @@ export default function AdminLegalSection() { } }; - if (loading) { + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return ( @@ -59,6 +69,7 @@ export default function AdminLegalSection() { return ( +
{t('admin.settings.legal.title', 'Legal Documents')} @@ -95,6 +106,7 @@ export default function AdminLegalSection() { value={settings.termsAndConditions || ''} onChange={(e) => setSettings({ ...settings, termsAndConditions: e.target.value })} placeholder="https://example.com/terms" + disabled={!loginEnabled} />
@@ -110,6 +122,7 @@ export default function AdminLegalSection() { value={settings.privacyPolicy || ''} onChange={(e) => setSettings({ ...settings, privacyPolicy: e.target.value })} placeholder="https://example.com/privacy" + disabled={!loginEnabled} /> @@ -125,6 +138,7 @@ export default function AdminLegalSection() { value={settings.accessibilityStatement || ''} onChange={(e) => setSettings({ ...settings, accessibilityStatement: e.target.value })} placeholder="https://example.com/accessibility" + disabled={!loginEnabled} /> @@ -140,6 +154,7 @@ export default function AdminLegalSection() { value={settings.cookiePolicy || ''} onChange={(e) => setSettings({ ...settings, cookiePolicy: e.target.value })} placeholder="https://example.com/cookies" + disabled={!loginEnabled} /> @@ -155,13 +170,14 @@ export default function AdminLegalSection() { value={settings.impressum || ''} onChange={(e) => setSettings({ ...settings, impressum: e.target.value })} placeholder="https://example.com/impressum" + disabled={!loginEnabled} />
- diff --git a/frontend/src/core/components/shared/config/configSections/AdminMailSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx similarity index 100% rename from frontend/src/core/components/shared/config/configSections/AdminMailSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx diff --git a/frontend/src/core/components/shared/config/configSections/AdminPremiumSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx similarity index 84% rename from frontend/src/core/components/shared/config/configSections/AdminPremiumSection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx index 8e18696cfc..5c5de2f213 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminPremiumSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPremiumSection.tsx @@ -7,6 +7,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface PremiumSettingsData { key?: string; @@ -15,6 +17,7 @@ interface PremiumSettingsData { export default function AdminPremiumSection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -30,10 +33,15 @@ export default function AdminPremiumSection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled]); const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } try { await saveSettings(); showRestartModal(); @@ -46,7 +54,9 @@ export default function AdminPremiumSection() { } }; - if (loading) { + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return ( @@ -56,6 +66,7 @@ export default function AdminPremiumSection() { return ( +
{t('admin.settings.premium.title', 'Premium & Enterprise')} @@ -98,6 +109,7 @@ export default function AdminPremiumSection() { value={settings.key || ''} onChange={(e) => setSettings({ ...settings, key: e.target.value })} placeholder="00000000-0000-0000-0000-000000000000" + disabled={!loginEnabled} />
@@ -111,7 +123,12 @@ export default function AdminPremiumSection() { setSettings({ ...settings, enabled: e.target.checked })} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, enabled: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -120,7 +137,7 @@ export default function AdminPremiumSection() { - diff --git a/frontend/src/core/components/shared/config/configSections/AdminPrivacySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx similarity index 78% rename from frontend/src/core/components/shared/config/configSections/AdminPrivacySection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx index 3f4a360a90..b3edede1c8 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminPrivacySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPrivacySection.tsx @@ -6,6 +6,8 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi import { useRestartServer } from '@app/components/shared/config/useRestartServer'; import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; import apiClient from '@app/services/apiClient'; interface PrivacySettingsData { @@ -16,6 +18,7 @@ interface PrivacySettingsData { export default function AdminPrivacySection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -76,10 +79,16 @@ export default function AdminPrivacySection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled, fetchSettings]); const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } + try { await saveSettings(); showRestartModal(); @@ -92,7 +101,10 @@ export default function AdminPrivacySection() { } }; - if (loading) { + // Override loading state when login is disabled + const actualLoading = loginEnabled ? loading : false; + + if (actualLoading) { return ( @@ -102,6 +114,8 @@ export default function AdminPrivacySection() { return ( + +
{t('admin.settings.privacy.title', 'Privacy')} @@ -123,8 +137,13 @@ export default function AdminPrivacySection() {
setSettings({ ...settings, enableAnalytics: e.target.checked })} + checked={settings?.enableAnalytics || false} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, enableAnalytics: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -139,8 +158,13 @@ export default function AdminPrivacySection() { setSettings({ ...settings, metricsEnabled: e.target.checked })} + checked={settings?.metricsEnabled || false} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, metricsEnabled: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -162,8 +186,13 @@ export default function AdminPrivacySection() { setSettings({ ...settings, googleVisibility: e.target.checked })} + checked={settings?.googleVisibility || false} + onChange={(e) => { + if (!loginEnabled) return; + setSettings({ ...settings, googleVisibility: e.target.checked }); + }} + disabled={!loginEnabled} + styles={getDisabledStyles()} /> @@ -173,7 +202,7 @@ export default function AdminPrivacySection() { {/* Save Button */} - diff --git a/frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx similarity index 85% rename from frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx rename to frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx index d9540937bf..8934f4c457 100644 --- a/frontend/src/core/components/shared/config/configSections/AdminSecuritySection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminSecuritySection.tsx @@ -8,6 +8,8 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer import { useAdminSettings } from '@app/hooks/useAdminSettings'; import PendingBadge from '@app/components/shared/config/PendingBadge'; import apiClient from '@app/services/apiClient'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; interface SecuritySettingsData { enableLogin?: boolean; @@ -44,6 +46,7 @@ interface SecuritySettingsData { export default function AdminSecuritySection() { const { t } = useTranslation(); + const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); const { @@ -157,10 +160,20 @@ export default function AdminSecuritySection() { }); useEffect(() => { - fetchSettings(); - }, []); + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled, fetchSettings]); + + // Override loading state when login is disabled + const actualLoading = loginEnabled ? loading : false; const handleSave = async () => { + // Block save if login is disabled + if (!validateLoginEnabled()) { + return; + } + try { await saveSettings(); showRestartModal(); @@ -173,7 +186,7 @@ export default function AdminSecuritySection() { } }; - if (loading) { + if (actualLoading) { return ( @@ -183,6 +196,8 @@ export default function AdminSecuritySection() { return ( + +
{t('admin.settings.security.title', 'Security')} @@ -204,8 +219,9 @@ export default function AdminSecuritySection() {
setSettings({ ...settings, enableLogin: e.target.checked })} + disabled={!loginEnabled} /> @@ -215,7 +231,7 @@ export default function AdminSecuritySection() { setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> +