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 01/12] 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 02/12] 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 03/12] [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 04/12] [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 05/12] 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 06/12] [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 07/12] 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 08/12] 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()}