diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 56ba745e9..50b80c32c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2117,22 +2117,6 @@ "tags": "differentiate,contrast,changes,analysis", "title": "Compare", "header": "Compare PDFs", - "workbench": { - "label": "Compare view" - }, - "view": { - "title": "Compare view", - "noData": "Run a comparison to view the summary and diff." - }, - "highlightColor": { - "1": "Highlight Colour 1:", - "2": "Highlight Colour 2:" - }, - "document": { - "1": "Document 1", - "2": "Document 2" - }, - "submit": "Compare", "review": { "title": "Comparison Result", "actionsHint": "Review the comparison, switch document roles, or export the summary.", @@ -2140,37 +2124,32 @@ "exportSummary": "Export summary" }, "base": { - "label": "Base document", - "placeholder": "Select a base PDF" + "label": "Original document", + "placeholder": "Select the original PDF" }, "comparison": { - "label": "Comparison document", - "placeholder": "Select a comparison PDF" + "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": { - "title": "Select Base and Comparison" + "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" }, - "swap": "Swap PDFs", "cta": "Compare", "loading": "Comparing...", - "upload": { - "title": "Set up your comparison", - "subtitle": "Add a base document on the left and a comparison document on the right to highlight their differences.", - "browse": "Browse files", - "selectExisting": "Select existing", - "clearSelection": "Clear selection", - "instructions": "Drag & drop here or use the buttons to choose a file.", - "baseTitle": "Base document", - "baseDescription": "This version acts as the reference for differences.", - "comparisonTitle": "Comparison document", - "comparisonDescription": "Differences from this version will be highlighted." - }, + "summary": { - "baseHeading": "Base document", - "comparisonHeading": "Comparison document", + "baseHeading": "Original document", + "comparisonHeading": "Edited document", "pageLabel": "Page" }, "rendering": { @@ -2180,6 +2159,8 @@ "complete": "Page rendering complete" }, "dropdown": { + "deletionsLabel": "Deletions", + "additionsLabel": "Additions", "deletions": "Deletions ({{count}})", "additions": "Additions ({{count}})", "searchPlaceholder": "Search changes...", @@ -2199,7 +2180,7 @@ "unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." }, "error": { - "selectRequired": "Select a base and comparison document.", + "selectRequired": "Select a original and edited document.", "filesMissing": "Unable to locate the selected files. Please re-select them.", "generic": "Unable to compare these files." }, @@ -2217,8 +2198,7 @@ "body": "This comparison is taking longer than usual. You can let it continue or cancel it.", "cancel": "Cancel comparison" }, - "pageCount": "{{count}} pages", - "lastModified": "Last modified", + "newLine": "new-line", "complex": { "message": "One or both of the provided documents are large files, accuracy of comparison may be reduced" diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index db057b46c..f8da18dd1 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -1281,59 +1281,30 @@ "tags": "differentiate,contrast,changes,analysis", "title": "Compare", "header": "Compare PDFs", - "description": "Select the base and comparison PDF to highlight differences.", - "view": { - "title": "Compare view", - "noData": "Run a comparison to view the summary and diff." - }, - "workbench": { - "label": "Compare view" - }, - "base": { - "label": "Base Document", - "placeholder": "Select a base PDF" - }, - "comparison": { - "label": "Comparison Document", - "placeholder": "Select a comparison PDF" - }, + "cta": "Compare", "loading": "Comparing...", "review": { - "title": "Comparison Result", - "actionsHint": "Review the comparison, switch document roles, or export the summary.", - "switchOrder": "Switch order", - "exportSummary": "Export summary" + "title": "Comparison Result" }, - "addFile": "Add File", - "replaceFile": "Replace File", "pages": "Pages", - "toggleLayout": "Toggle layout", - "upload": { - "title": "Set up your comparison", - "baseTitle": "Base document", - "baseDescription": "This version acts as the reference for differences.", - "comparisonTitle": "Comparison document", - "comparisonDescription": "Differences from this version will be highlighted.", - "browse": "Browse files", - "selectExisting": "Select existing", - "clearSelection": "Clear selection", - "instructions": "Drag & drop here or use the buttons to choose a file." - }, + "legend": { "removed": "Removed from base", "added": "Added in comparison" }, "newLine": "new-line", "dropdown": { + "deletionsLabel": "Deletions", + "additionsLabel": "Additions", "deletions": "Deletions ({{count}})", "additions": "Additions ({{count}})", "searchPlaceholder": "Search changes...", "noResults": "No changes found" }, "summary": { - "baseHeading": "Base document", - "comparisonHeading": "Comparison document", + "baseHeading": "Original document", + "comparisonHeading": "Edited document", "pageLabel": "Page" }, "rendering": { @@ -1345,7 +1316,17 @@ "status": { "extracting": "Extracting text...", "processing": "Analyzing differences...", - "complete": "Comparison ready" + "complete": "Edited ready" + }, + "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" }, "longJob": { "title": "Large comparison in progress", @@ -1357,7 +1338,7 @@ "cancel": "Cancel comparison" }, "error": { - "selectRequired": "Select a base and comparison document.", + "selectRequired": "Select a original and edited document.", "filesMissing": "Unable to locate the selected files. Please re-select them.", "generic": "Unable to compare these files." }, @@ -1374,11 +1355,6 @@ "message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." } }, - "no": { - "text": { - "message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." - } - }, "large.file.message": "One or Both of the provided documents are too large to process", "complex.message": "One or both of the provided documents are large files, accuracy of comparison may be reduced", "no.text.message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison." @@ -1625,7 +1601,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 2a2264977..8210345e1 100644 --- a/frontend/public/locales/eu-ES/translation.json +++ b/frontend/public/locales/eu-ES/translation.json @@ -1254,7 +1254,7 @@ "tags": "Overlay", "header": "Overlay PDF Files", "baseFile": { - "label": "Select Base PDF File" + "label": "Select Original PDF File" }, "overlayFiles": { "label": "Select Overlay PDF Files" diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx index b835b2d7b..a89568866 100644 --- a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -1,5 +1,5 @@ -import { Alert, Group, Loader, Stack, Text } from '@mantine/core'; -import { useMemo } from 'react'; +import { Group, Loader, Stack, Text } from '@mantine/core'; +import { useMemo, useRef, useState } from 'react'; import type { PagePreview } from '@app/types/compare'; import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare'; import CompareNavigationDropdown from './CompareNavigationDropdown'; @@ -76,7 +76,6 @@ const CompareDocumentPane = ({ onNavigateChange, isLoading, processingMessage, - emptyMessage, pages, pairedPages, getRowHeightPx, @@ -102,6 +101,10 @@ const CompareDocumentPane = ({ const panX = (pan?.x ?? 0); const panY = (pan?.y ?? 0); + // Track which page images have finished loading to avoid flashing between states + const imageLoadedRef = useRef>(new Map()); + const [, forceRerender] = useState(0); + return (
@@ -109,14 +112,14 @@ const CompareDocumentPane = ({ {title} - {changes.length > 0 && ( - new Set(pages.map(p => p.pageNumber)), [pages])} - /> + {(changes.length > 0 || Boolean(dropdownPlaceholder)) && ( + new Set(pages.map(p => p.pageNumber)), [pages])} + /> )}
@@ -143,12 +146,6 @@ const CompareDocumentPane = ({ )} - {!isLoading && pages.length === 0 && ( - - {emptyMessage} - - )} - {pages.map((page) => { const peerPage = pairedPageMap.get(page.pageNumber); const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height; @@ -175,11 +172,12 @@ const CompareDocumentPane = ({ current.push(rect); groupedRects.set(id, current); } + const preloadMarginPx = Math.max(rowHeightPx * 5, 1200); // render several pages ahead to hide loading flashes return ( - - {documentLabel} · {pageLabel} {page.pageNumber} - +
+ + {documentLabel} · {pageLabel} {page.pageNumber} + +
- - {documentLabel} · {pageLabel} {page.pageNumber} - +
+ + {documentLabel} · {pageLabel} {page.pageNumber} + +
+ {/* Image layer */} {altLabel} { + if (!imageLoadedRef.current.get(page.pageNumber)) { + imageLoadedRef.current.set(page.pageNumber, true); + forceRerender(v => v + 1); + } + }} /> + {/* 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 = ((page.rotation ?? 0) % 360 + 360) % 360; diff --git a/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx b/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx index 1b78e1ca3..ab7a74689 100644 --- a/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx +++ b/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx @@ -64,11 +64,9 @@ const CompareNavigationDropdown = ({ (() => { try { // Construct at runtime so old engines don’t fail parse-time - console.debug('Using Unicode props'); return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u'); } catch { // Fallback (no Unicode props): letters, digits, and common punctuation/symbols - console.debug('No Unicode props, falling back to ASCII class'); return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/; } })(); @@ -153,7 +151,7 @@ const CompareNavigationDropdown = ({ className={['compare-changes-select', className].filter(Boolean).join(' ')} onClick={() => combobox.toggleDropdown()} > - {placeholder} + {placeholder}
diff --git a/frontend/src/core/components/tools/compare/CompareUploadSection.tsx b/frontend/src/core/components/tools/compare/CompareUploadSection.tsx deleted file mode 100644 index 463d4779d..000000000 --- a/frontend/src/core/components/tools/compare/CompareUploadSection.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useMemo, useRef } from 'react'; -import { Button, Stack, Text } from '@mantine/core'; -import type { ForwardedRef } from 'react'; -import { Dropzone } from '@mantine/dropzone'; -import { formatFileSize } from '@app/utils/fileUtils'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { useTranslation } from 'react-i18next'; -import type { UploadColumnProps, CompareUploadSectionProps } from '@app/types/compare'; - -const CompareUploadColumn = ({ - role, - file, - stub, - title, - description, - accentClass, - disabled, - onDrop, - onSelectExisting, - onClear, -}: UploadColumnProps) => { - const { t } = useTranslation(); - const openRef = useRef<(() => void) | null>(null); - - const fileLabel = useMemo(() => { - const fileName = stub?.name ?? file?.name ?? null; - const fileSize = stub?.size ?? file?.size ?? null; - if (!fileName) { - return null; - } - return fileSize ? `${fileName} • ${formatFileSize(fileSize)}` : fileName; - }, [file, stub]); - - return ( -
- void | undefined) | null) => { - openRef.current = instance ?? null; - }) as ForwardedRef<() => void | undefined>} - onDrop={onDrop} - disabled={disabled} - multiple - className="compare-upload-dropzone" - > -
-
- -
- - {title} - - - {description} - - -
- - -
- - {fileLabel ? ( -
- - {fileLabel} - - -
- ) : ( - - {t('compare.upload.instructions', 'Drag & drop here or use the buttons to choose a file.')} - - )} -
-
-
- ); -}; - -const CompareUploadSection = ({ - heading, - subheading, - disabled, - base, - comparison, -}: CompareUploadSectionProps) => { - return ( - - - - {heading} - - - {subheading} - - -
- - - - ); -}; - -export default CompareUploadSection; diff --git a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx index 61b626ec0..f3221dd76 100644 --- a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { Stack } from '@mantine/core'; +import { useEffect, useMemo, useRef } from 'react'; +import { Loader, Stack } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '@app/hooks/useIsMobile'; import { @@ -13,7 +13,6 @@ import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useFileActions, useFileContext } from '@app/contexts/file/fileHooks'; import { useRightRailButtons } from '@app/hooks/useRightRailButtons'; import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane'; -import CompareUploadSection from '@app/components/tools/compare/CompareUploadSection'; 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'; @@ -154,104 +153,21 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { ); const processingMessage = t('compare.status.processing', 'Analyzing differences...'); - const emptyMessage = t('compare.view.noData', 'Run a comparison to view the summary and diff.'); - const baseDocumentLabel = t('compare.summary.baseHeading', 'Base document'); - const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Comparison document'); + const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document'); + const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document'); const pageLabel = t('compare.summary.pageLabel', 'Page'); - const handleFilesAdded = useCallback(async (files: File[], role: 'base' | 'comparison') => { - if (!files.length || isOperationLoading) { - return; - } - try { - const added = await fileActions.addFiles(files, { selectFiles: false }); - const primary = added[0]; - if (!primary) { - return; - } - if (role === 'base') { - onSelectBase?.(primary.fileId as FileId); - } else { - onSelectComparison?.(primary.fileId as FileId); - } - } catch (error) { - console.error('[compare] failed to add files from workbench dropzone', error); - } - }, [fileActions, isOperationLoading, onSelectBase, onSelectComparison]); + // 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 || ''; - const handleSelectFromLibrary = useCallback((role: 'base' | 'comparison') => { - if (isOperationLoading) { - return; - } - openFilesModal({ - customHandler: async (files: File[]) => { - await handleFilesAdded(files, role); - }, - }); - }, [handleFilesAdded, isOperationLoading, openFilesModal]); - - const handleClearSelection = useCallback((role: 'base' | 'comparison') => { - if (isOperationLoading) { - return; - } - if (role === 'base') { - onSelectBase?.(null); - } else { - onSelectComparison?.(null); - } - }, [isOperationLoading, onSelectBase, onSelectComparison]); - - const uploadSection = ( - handleFilesAdded(files, 'base'), - () => handleSelectFromLibrary('base'), - () => handleClearSelection('base'), - isOperationLoading, - )} - comparison={getUploadConfig( - 'comparison', - comparisonFile, - comparisonStub, - t('compare.upload.comparisonTitle', 'Comparison document'), - t('compare.upload.comparisonDescription', 'Differences from this version will be highlighted.'), - 'compare-upload-icon--comparison', - (files) => handleFilesAdded(files, 'comparison'), - () => handleSelectFromLibrary('comparison'), - () => handleClearSelection('comparison'), - isOperationLoading, - )} - /> - ); - - if (!result) { - return uploadSection; - } - - const baseTitle = baseLoading - ? `${result.base.fileName} - ${t('loading', 'Loading')}…` - : `${result.base.fileName} - ${basePages.length} pages`; - const comparisonTitle = comparisonLoading - ? `${result.comparison.fileName} - ${t('loading', 'Loading')}…` - : `${result.comparison.fileName} - ${comparisonPages.length} pages`; - const baseDropdownPlaceholder = t('compare.dropdown.deletions', 'Deletions ({{count}})', { - count: baseWordChanges.length, - }); - const comparisonDropdownPlaceholder = t('compare.dropdown.additions', 'Additions ({{count}})', { - count: comparisonWordChanges.length, - }); + // 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, @@ -291,6 +207,19 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { 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); @@ -393,9 +322,8 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { dropdownPlaceholder={baseDropdownPlaceholder} changes={mapChangesForDropdown(baseWordChanges)} onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)} - isLoading={baseLoading} + isLoading={isOperationLoading || baseLoading} processingMessage={processingMessage} - emptyMessage={emptyMessage} pages={basePages} pairedPages={comparisonPages} getRowHeightPx={getRowHeightPx} @@ -426,9 +354,8 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { dropdownPlaceholder={comparisonDropdownPlaceholder} changes={mapChangesForDropdown(comparisonWordChanges)} onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)} - isLoading={comparisonLoading} + isLoading={isOperationLoading || comparisonLoading} processingMessage={processingMessage} - emptyMessage={emptyMessage} pages={comparisonPages} pairedPages={basePages} getRowHeightPx={getRowHeightPx} diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css index 1785ab601..e889e5d4d 100644 --- a/frontend/src/core/components/tools/compare/compareView.css +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -114,6 +114,17 @@ 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: rgba(52, 199, 89, 0.18) !important; color: #1b5e20 !important; @@ -342,6 +353,8 @@ margin-left: auto; margin-right: auto; max-width: 100%; + background-color: #fff; /* ensure stable white backing during load */ + border: 1px solid var(--border-subtle); } .compare-diff-page__image { @@ -351,6 +364,24 @@ object-fit: contain; } +/* Centered per-page title wrapper */ +.compare-page-title { + text-align: center; + margin-left: auto; + margin-right: auto; +} + +/* 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; @@ -418,10 +449,6 @@ background-color: rgba(52, 199, 89, 0.25); } -.compare-workbench--upload { - padding: 2.5rem 1.5rem; -} - .compare-pane-header { position: sticky; top: 0; @@ -430,92 +457,6 @@ padding: 0.25rem 0; } -.compare-upload-layout { - display: flex; - align-items: stretch; - gap: 2rem; - width: 100%; -} - -.compare-upload-divider { - width: 1px; - background: var(--compare-upload-divider); -} - -.compare-upload-column { - flex: 1; - display: flex; -} - -.compare-upload-dropzone { - flex: 1; - border: 1px dashed var(--compare-upload-dropzone-border); - border-radius: 1rem; - background: var(--compare-upload-dropzone-bg); - padding: 0; - transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; -} - -.compare-upload-dropzone[data-accept] { - border-color: rgba(52, 199, 89, 0.6); - background: rgba(52, 199, 89, 0.12); - box-shadow: 0 0 0 2px rgba(52, 199, 89, 0.2); -} - -.compare-upload-dropzone[data-reject] { - border-color: rgba(255, 59, 48, 0.6); - background: rgba(255, 59, 48, 0.12); - box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.2); -} - -.compare-upload-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - text-align: center; - padding: 2.5rem 2rem; - min-height: 20rem; - width: 100%; -} - -.compare-upload-icon { - display: flex; - align-items: center; - justify-content: center; - width: 3.25rem; - height: 3.25rem; - border-radius: 999px; - color: var(--compare-upload-icon-color); - background: var(--compare-upload-icon-bg); -} - -.compare-upload-icon--base { - color: rgba(255, 59, 48, 0.8); - background: rgba(255, 59, 48, 0.12); -} - -.compare-upload-icon--comparison { - color: rgba(52, 199, 89, 0.85); - background: rgba(52, 199, 89, 0.14); -} - -.compare-upload-actions { - display: flex; - flex-direction: column; - gap: 0.75rem; - width: min(18rem, 100%); -} - -.compare-upload-selection { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: min(20rem, 100%); - align-items: center; -} - /* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */ .compare-tool__thumbnail { width: 4rem; @@ -528,20 +469,6 @@ min-width: 0; } -@media (max-width: 960px) { - .compare-upload-layout { - flex-direction: column; - } - - .compare-upload-divider { - display: none; - } - - .compare-upload-card { - min-height: 18rem; - } -} - /* Mobile: remove side margins and let canvases take full width inside column */ @media (max-width: 768px) { .compare-workbench__columns { diff --git a/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts index 4c3f62639..5aacc5fde 100644 --- a/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts +++ b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts @@ -6,11 +6,16 @@ const DISPLAY_SCALE = 1; const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1); +// Simple shared cache so rendering progress can resume across unmounts/remounts +const previewCache: Map = new Map(); + 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, { @@ -25,7 +30,10 @@ const renderPdfDocumentToImages = async ( onInitTotal?.(pdf.numPages); let batch: PagePreview[] = []; - for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + 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 }); @@ -40,26 +48,32 @@ const renderPdfDocumentToImages = async ( continue; } - await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise; - 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 = []; + 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; } - page.cleanup(); - canvas.width = 0; - canvas.height = 0; + if (shouldStop()) break; } if (onBatch && batch.length > 0) onBatch(batch); @@ -91,6 +105,28 @@ export const useComparePagePreviews = ({ if (!file || !enabled) { setPages([]); setLoading(false); + setTotalPages(0); + return () => { + cancelled = true; + }; + } + + const key = `${(file as any).name || 'file'}:${(file as any).size || 0}:${cacheKey ?? 'none'}`; + const cached = previewCache.get(key); + const cachedTotal = cached?.total ?? (cached?.pages.length ?? 0); + let lastKnownTotal = cachedTotal; + const isFullyCached = Boolean(cached && cached.pages.length > 0 && cachedTotal > 0 && cached.pages.length >= cachedTotal); + + if (cached) { + setPages(cached.pages.slice()); + setTotalPages(cachedTotal); + } else { + setTotalPages(0); + } + + setLoading(!isFullyCached); + + if (isFullyCached) { return () => { cancelled = true; }; @@ -101,6 +137,7 @@ export const useComparePagePreviews = ({ try { inFlightRef.current += 1; const current = inFlightRef.current; + const startAt = (cached?.pages?.length ?? 0) + 1; const previews = await renderPdfDocumentToImages( file, (batch) => { @@ -112,16 +149,32 @@ export const useComparePagePreviews = ({ const idx = next.findIndex((x) => x.pageNumber > p.pageNumber); if (idx === -1) next.push(p); else next.splice(idx, 0, p); } + // Update shared cache + previewCache.set(key, { pages: next, total: lastKnownTotal || cachedTotal }); return next; }); }, 16, (total) => { - if (!cancelled && current === inFlightRef.current) setTotalPages(total); - } + if (!cancelled && current === inFlightRef.current) { + lastKnownTotal = total; + setTotalPages(total); + // Initialize or update cache record while preserving any pages + const existingPages = previewCache.get(key)?.pages ?? []; + previewCache.set(key, { pages: existingPages.slice(), total }); + } + }, + startAt, + () => cancelled || current !== inFlightRef.current ); - if (!cancelled) { - setPages(previews); + if (!cancelled && current === inFlightRef.current) { + const cacheEntry = previewCache.get(key); + const finalTotal = lastKnownTotal || cachedTotal || cacheEntry?.total || previews.length; + lastKnownTotal = finalTotal; + const finalPages = cacheEntry ? cacheEntry.pages.slice() : previews.slice(); + previewCache.set(key, { pages: finalPages.slice(), total: finalTotal }); + setPages(finalPages); + setTotalPages(finalTotal); } } catch (error) { console.error('[compare] failed to render document preview', error); diff --git a/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts b/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts index f7502592f..cde2cd33b 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/hooks/tools/compare/useCompareOperation.ts b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts index ade671e33..c85d9bc60 100644 --- a/frontend/src/core/hooks/tools/compare/useCompareOperation.ts +++ b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts @@ -193,7 +193,7 @@ export const useCompareOperation = (): CompareOperationHook => { const runId = ++activeRunIdRef.current; cancelledRef.current = false; if (!params.baseFileId || !params.comparisonFileId) { - setErrorMessage(t('compare.error.selectRequired', 'Select a base and comparison document.')); + setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.')); return; } diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index 189fd647d..d6ebb48be 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +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 } from '@mantine/core'; -import { Tooltip } from '@app/components/shared/Tooltip'; +import { Box, Group, Stack, Text, Button, Modal } from '@mantine/core'; +import SwapVertRoundedIcon from '@mui/icons-material/SwapVertRounded'; import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; import { BaseToolProps, ToolComponent } from '@app/types/tool'; @@ -16,7 +16,7 @@ import { } from '@app/hooks/tools/compare/useCompareOperation'; import CompareWorkbenchView from '@app/components/tools/compare/CompareWorkbenchView'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import { useNavigationActions } from '@app/contexts/NavigationContext'; import { useFileContext } from '@app/contexts/file/fileHooks'; import type { FileId } from '@app/types/file'; import type { StirlingFile } from '@app/types/fileContext'; @@ -30,7 +30,6 @@ const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const; const Compare = (props: BaseToolProps) => { const { t } = useTranslation(); const { actions: navigationActions } = useNavigationActions(); - const navigationState = useNavigationState(); const { registerCustomWorkbenchView, unregisterCustomWorkbenchView, @@ -51,6 +50,7 @@ const Compare = (props: BaseToolProps) => { const params = base.params.parameters; const compareIcon = useMemo(() => , []); + const [swapConfirmOpen, setSwapConfirmOpen] = useState(false); useEffect(() => { registerCustomWorkbenchView({ @@ -63,7 +63,6 @@ const Compare = (props: BaseToolProps) => { }); return () => { - clearCustomWorkbenchViewData(CUSTOM_VIEW_ID); unregisterCustomWorkbenchView(CUSTOM_VIEW_ID); }; // Register once; avoid re-registering on translation/prop changes which clears data mid-flight @@ -96,53 +95,101 @@ const Compare = (props: BaseToolProps) => { } }, [base.selectedFiles, base.params, params.baseFileId, params.comparisonFileId]); - // Only switch to custom view once per result (prevents update loops) + // 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) => { + if (!baseId || !compId) { + return; + } + + updateWorkbenchData({ + result: null, + baseFileId: baseId, + comparisonFileId: compId, + baseLocalFile: lastWorkbenchDataRef.current?.baseLocalFile ?? null, + comparisonLocalFile: lastWorkbenchDataRef.current?.comparisonLocalFile ?? null, + isLoading: true, + }); + + lastProcessedAtRef.current = null; + }, + [operation.result, updateWorkbenchData] + ); + useEffect(() => { - const { result } = operation; - const { baseFileId, comparisonFileId } = params; + 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; - const hasSelection = Boolean(baseFileId && comparisonFileId); - const matchesSelection = Boolean( + + if ( result && - hasSelection && + processedAt !== null && + processedAt !== lastProcessedAtRef.current && result.base.fileId === baseFileId && result.comparison.fileId === comparisonFileId - ); - - - if (matchesSelection && result && processedAt !== null && processedAt !== lastProcessedAtRef.current) { - - const workbenchData: CompareWorkbenchData = { + ) { + updateWorkbenchData({ result, baseFileId, comparisonFileId, baseLocalFile: null, comparisonLocalFile: null, - }; - setCustomWorkbenchViewData(CUSTOM_VIEW_ID, workbenchData); - // Defer workbench switch to the next frame so the data update is visible to the provider - requestAnimationFrame(() => { - - - navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); + isLoading: false, }); lastProcessedAtRef.current = processedAt; + return; } - if (!result) { - lastProcessedAtRef.current = null; - clearCustomWorkbenchViewData(CUSTOM_VIEW_ID); + if (base.operation.isLoading) { + updateWorkbenchData({ + result: null, + baseFileId, + comparisonFileId, + baseLocalFile: lastWorkbenchDataRef.current?.baseLocalFile ?? null, + comparisonLocalFile: lastWorkbenchDataRef.current?.comparisonLocalFile ?? null, + isLoading: true, + }); + return; } }, [ + base.operation.isLoading, clearCustomWorkbenchViewData, - navigationActions, - navigationState.selectedTool, operation.result, params.baseFileId, params.comparisonFileId, - setCustomWorkbenchViewData, - params, + updateWorkbenchData, ]); const handleExecuteCompare = useCallback(async () => { @@ -151,11 +198,21 @@ const Compare = (props: BaseToolProps) => { const compSel = params.comparisonFileId ? selectors.getFile(params.comparisonFileId) : null; if (baseSel) selected.push(baseSel); if (compSel) selected.push(compSel); + + const baseId = params.baseFileId as FileId | null; + const compId = params.comparisonFileId as FileId | null; + prepareWorkbenchForRun(baseId, compId); + if (baseId && compId) { + requestAnimationFrame(() => { + navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); + }); + } + await operation.executeOperation( { ...params }, selected ); - }, [operation, params, selectors]); + }, [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) => { @@ -165,10 +222,11 @@ const Compare = (props: BaseToolProps) => { const compSel = compId ? selectors.getFile(compId) : null; if (baseSel) selected.push(baseSel); if (compSel) selected.push(compSel); + prepareWorkbenchForRun(baseId, compId); await operation.executeOperation(nextParams, selected); - }, [operation, params, selectors]); + }, [operation, params, prepareWorkbenchForRun, selectors]); - const handleSwap = useCallback(() => { + const performSwap = useCallback(() => { const baseId = params.baseFileId as FileId | null; const compId = params.comparisonFileId as FileId | null; if (!baseId || !compId) return; @@ -177,11 +235,21 @@ const Compare = (props: BaseToolProps) => { baseFileId: compId, comparisonFileId: baseId, })); - // If we already have a comparison result, re-run automatically using the swapped ids. if (operation.result) { runCompareWithIds(compId, baseId); } - }, [base.params, params.baseFileId, params.comparisonFileId, operation.result, runCompareWithIds]); + }, [base.params, operation.result, params.baseFileId, params.comparisonFileId, runCompareWithIds]); + + 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') => { @@ -190,63 +258,77 @@ const Compare = (props: BaseToolProps) => { if (!stub) { return ( - - - {t( - role === 'base' ? 'compare.base.placeholder' : 'compare.comparison.placeholder', - role === 'base' ? 'Select a base PDF' : 'Select a comparison PDF' - )} + + + {role === 'base' ? t('compare.original.label', 'Original PDF') : t('compare.edited.label', 'Edited PDF')} - + + + {t( + role === 'base' ? 'compare.original.placeholder' : 'compare.edited.placeholder', + role === 'base' ? 'Select the original PDF' : 'Select the edited PDF' + )} + + + ); } - + // 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; - const meta = [dateText, pageCount ? `${pageCount} ${t('compare.pages', 'Pages')}` : null] - .filter(Boolean) - .join(' - '); return ( - - - - - - - + + + {role === 'base' ? t('compare.original.label', 'Original PDF') : t('compare.edited.label', 'Edited PDF')} + + + + + + + - - {meta && ( - - {meta} + {pageCount && dateText && ( + <> + + {pageCount} {t('compare.pages', 'pages')} +
+ {dateText}
- )} -
-
-
+ + )} + + + + ); }, [params.baseFileId, params.comparisonFileId, selectors, t] @@ -256,6 +338,8 @@ const Compare = (props: BaseToolProps) => { params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId && !base.operation.isLoading && base.endpointEnabled !== false ); + const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId); + return createToolFlow({ files: { selectedFiles: base.selectedFiles, @@ -263,22 +347,85 @@ const Compare = (props: BaseToolProps) => { }, steps: [ { - title: t('compare.selection.title', 'Select Base and Comparison'), + title: t('compare.selection.originalEditedTitle', 'Select Original and Edited PDFs'), isVisible: true, content: ( - - {renderSelectedFile('base')} - {renderSelectedFile('comparison')} - + + + {renderSelectedFile('base')} +
+ {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?')} + + + + + + + ), }, ], diff --git a/frontend/src/core/types/compare.ts b/frontend/src/core/types/compare.ts index a93454d6f..17ef4db13 100644 --- a/frontend/src/core/types/compare.ts +++ b/frontend/src/core/types/compare.ts @@ -156,12 +156,11 @@ export interface CompareDocumentPaneProps { zoom: number; pan?: { x: number; y: number }; title: string; - dropdownPlaceholder?: string; + dropdownPlaceholder?: React.ReactNode; changes: Array<{ value: string; label: string; pageNumber?: number }>; onNavigateChange: (id: string, pageNumber?: number) => void; isLoading: boolean; processingMessage: string; - emptyMessage: string; pages: PagePreview[]; pairedPages: PagePreview[]; getRowHeightPx: (pageNumber: number) => number; @@ -188,7 +187,7 @@ export interface WordHighlightEntry { export interface NavigationDropdownProps { changes: Array<{ value: string; label: string; pageNumber?: number }>; - placeholder: string; + placeholder: React.ReactNode; className?: string; onNavigate: (value: string, pageNumber?: number) => void; // Optional: pages that currently have previews rendered (1-based page numbers) @@ -284,26 +283,7 @@ export interface WordHighlightEntry { metaIndex: number; } -export interface UploadColumnProps { - role: 'base' | 'comparison'; - file: File | null; - stub: StirlingFileStub | null; - title: string; - description: string; - accentClass: string; - disabled: boolean; - onDrop: (files: File[]) => void; - onSelectExisting: () => void; - onClear: () => void; -} - -export interface CompareUploadSectionProps { - heading: string; - subheading: string; - disabled: boolean; - base: UploadColumnProps; - comparison: UploadColumnProps; -} +// Removed legacy upload section types; upload flow now uses the standard active files workbench export interface CompareWorkbenchData { result: CompareResultData | null; diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index 2baf7586b..25c187020 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;