tranlation files, remove unused file/functions/styles, fix buggy behaviour

This commit is contained in:
EthanHealy01 2025-10-30 00:24:32 +00:00
parent f1e3c93890
commit 65a30aece7
14 changed files with 480 additions and 586 deletions

View File

@ -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"

View File

@ -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",

View File

@ -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"

View File

@ -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<Map<number, boolean>>(new Map());
const [, forceRerender] = useState(0);
return (
<div className="compare-pane">
<div className="compare-header">
@ -109,14 +112,14 @@ const CompareDocumentPane = ({
<Text fw={600} size="lg">
{title}
</Text>
{changes.length > 0 && (
<CompareNavigationDropdown
changes={changes}
placeholder={dropdownPlaceholder ?? ''}
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
onNavigate={onNavigateChange}
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
/>
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
<CompareNavigationDropdown
changes={changes}
placeholder={dropdownPlaceholder ?? null}
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
onNavigate={onNavigateChange}
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
/>
)}
</Group>
</div>
@ -143,12 +146,6 @@ const CompareDocumentPane = ({
</Group>
)}
{!isLoading && pages.length === 0 && (
<Alert color="gray" variant="light">
<Text size="sm">{emptyMessage}</Text>
</Alert>
)}
{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 (
<LazyLoadContainer
key={`${pane}-page-${page.pageNumber}`}
rootMargin="100px"
rootMargin={`${preloadMarginPx}px 0px ${preloadMarginPx}px 0px`}
threshold={0.1}
fallback={
<div
@ -187,16 +185,25 @@ const CompareDocumentPane = ({
data-page-number={page.pageNumber}
style={{ minHeight: `${rowHeightPx}px` }}
>
<Text size="xs" fw={600} c="dimmed">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
<div
className="compare-page-title"
style={isStackedPortrait
? { width: `${stackedWidth}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<Text size="xs" fw={600} c="dimmed" ta="center">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
</div>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={isStackedPortrait
? { width: `${stackedWidth}px`, height: `${stackedHeight}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px` }}
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<div
className="compare-diff-page__inner"
@ -225,27 +232,52 @@ const CompareDocumentPane = ({
data-page-number={page.pageNumber}
style={{ minHeight: `${rowHeightPx}px` }}
>
<Text size="xs" fw={600} c="dimmed">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
<div
className="compare-page-title"
style={isStackedPortrait
? { width: `${stackedWidth}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<Text size="xs" fw={600} c="dimmed" ta="center">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
</div>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={isStackedPortrait
? { width: `${stackedWidth}px`, height: `${stackedHeight}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px` }}
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<div
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
style={{ transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`, transformOrigin: 'top left' }}
style={{
transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`,
transformOrigin: 'top left'
}}
>
{/* Image layer */}
<img
src={page.url ?? ''}
alt={altLabel}
loading="lazy"
className="compare-diff-page__image"
onLoad={() => {
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)) && (
<div className="compare-page-loader-overlay">
<Loader size="sm" />
</div>
)}
{[...groupedRects.entries()].flatMap(([id, rects]) =>
mergeConnectedRects(rects).map((rect, index) => {
const rotation = ((page.rotation ?? 0) % 360 + 360) % 360;

View File

@ -64,11 +64,9 @@ const CompareNavigationDropdown = ({
(() => {
try {
// Construct at runtime so old engines dont 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()}
>
<span>{placeholder}</span>
<span className="compare-changes-select__placeholder">{placeholder}</span>
<Combobox.Chevron />
</div>
</Combobox.Target>

View File

@ -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 (
<div className="compare-upload-column" key={`upload-column-${role}`}>
<Dropzone
openRef={((instance: (() => void | undefined) | null) => {
openRef.current = instance ?? null;
}) as ForwardedRef<() => void | undefined>}
onDrop={onDrop}
disabled={disabled}
multiple
className="compare-upload-dropzone"
>
<div className="compare-upload-card">
<div className={`compare-upload-icon ${accentClass}`}>
<LocalIcon icon="upload" width="2.5rem" height="2.5rem" />
</div>
<Text fw={600} size="lg">
{title}
</Text>
<Text size="sm" c="dimmed" ta="center">
{description}
</Text>
<div className="compare-upload-actions">
<Button
onClick={() => openRef.current?.()}
disabled={disabled}
fullWidth
>
{t('compare.upload.browse', 'Browse files')}
</Button>
<Button
variant="outline"
onClick={onSelectExisting}
disabled={disabled}
fullWidth
>
{t('compare.upload.selectExisting', 'Select existing')}
</Button>
</div>
{fileLabel ? (
<div className="compare-upload-selection">
<Text size="sm" fw={500} lineClamp={2}>
{fileLabel}
</Text>
<Button
variant="subtle"
color="gray"
onClick={onClear}
disabled={disabled}
size="xs"
>
{t('compare.upload.clearSelection', 'Clear selection')}
</Button>
</div>
) : (
<Text size="xs" c="dimmed" ta="center">
{t('compare.upload.instructions', 'Drag & drop here or use the buttons to choose a file.')}
</Text>
)}
</div>
</Dropzone>
</div>
);
};
const CompareUploadSection = ({
heading,
subheading,
disabled,
base,
comparison,
}: CompareUploadSectionProps) => {
return (
<Stack className="compare-workbench compare-workbench--upload" gap="lg">
<Stack gap={4} align="center">
<Text fw={600} size="lg">
{heading}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={520}>
{subheading}
</Text>
</Stack>
<div className="compare-upload-layout">
<CompareUploadColumn {...base} disabled={disabled} />
<div className="compare-upload-divider" aria-hidden="true" />
<CompareUploadColumn {...comparison} disabled={disabled} />
</div>
</Stack>
);
};
export default CompareUploadSection;

View File

@ -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 = (
<CompareUploadSection
heading={t('compare.upload.title', 'Set up your comparison')}
subheading={t(
'compare.upload.subtitle',
'Add a base document on the left and a comparison document on the right to highlight their differences.'
)}
disabled={isOperationLoading}
base={getUploadConfig(
'base',
baseFile,
baseStub,
t('compare.upload.baseTitle', 'Base document'),
t('compare.upload.baseDescription', 'This version acts as the reference for differences.'),
'compare-upload-icon--base',
(files) => 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)
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.deletionsLabel', 'Deletions')} <Loader size="xs" color="currentColor" /></span>)
: t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length });
const comparisonDropdownPlaceholder = (isOperationLoading || !result)
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.additionsLabel', 'Additions')} <Loader size="xs" color="currentColor" /></span>)
: t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length });
const rightRailButtons = useCompareRightRailButtons({
layout,
@ -291,6 +207,19 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const progressToastIdRef = useRef<string | null>(null);
const completionTimerRef = useRef<number | null>(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}

View File

@ -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 {

View File

@ -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<string, { pages: PagePreview[]; total: number }> = new Map();
const renderPdfDocumentToImages = async (
file: File,
onBatch?: (previews: PagePreview[]) => void,
batchSize: number = 12,
onInitTotal?: (totalPages: number) => void,
startAtPage: number = 1,
shouldAbort?: () => boolean,
): Promise<PagePreview[]> => {
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);

View File

@ -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.'
)
},
{

View File

@ -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;
}

View File

@ -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(() => <CompareRoundedIcon fontSize="small" />, []);
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<number | null>(null);
const lastWorkbenchDataRef = useRef<CompareWorkbenchData | null>(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 (
<Box
style={{
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '0.75rem 1rem',
background: 'var(--bg-surface)'
}}
>
<Text size="sm" c="dimmed">
{t(
role === 'base' ? 'compare.base.placeholder' : 'compare.comparison.placeholder',
role === 'base' ? 'Select a base PDF' : 'Select a comparison PDF'
)}
<Stack gap={6}>
<Text fw={700} size="sm">
{role === 'base' ? t('compare.original.label', 'Original PDF') : t('compare.edited.label', 'Edited PDF')}
</Text>
</Box>
<Box
style={{
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '0.75rem 1rem',
background: 'var(--bg-surface)',
width: '100%',
}}
>
<Text size="sm" c="dimmed">
{t(
role === 'base' ? 'compare.original.placeholder' : 'compare.edited.placeholder',
role === 'base' ? 'Select the original PDF' : 'Select the edited PDF'
)}
</Text>
</Box>
</Stack>
);
}
// 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 (
<Box
style={{
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '0.75rem 1rem',
background: 'var(--bg-surface)'
}}
>
<Group align="flex-start" wrap="nowrap" gap="md">
<Box className="compare-tool__thumbnail">
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
</Box>
<Stack gap={4} className="compare-tool__details">
<Tooltip content={stub?.name || ''} position="top" arrow>
<Stack gap={6}>
<Text fw={700} size="sm">
{role === 'base' ? t('compare.original.label', 'Original PDF') : t('compare.edited.label', 'Edited PDF')}
</Text>
<Box
style={{
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '0.75rem 1rem',
background: 'var(--bg-surface)',
width: '100%',
minHeight: "9rem"
}}
>
<Group align="flex-start" wrap="nowrap" gap="md">
<Box className="compare-tool__thumbnail" style={{ alignSelf: 'center' }}>
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
</Box>
<Stack className="compare-tool__details">
<FitText
text={stub?.name || ''}
minimumFontScale={0.5}
lines={2}
style={{ fontWeight: 600 }}
minimumFontScale={0.8}
lines={3}
style={{ fontWeight: 600
}}
/>
</Tooltip>
{meta && (
<Text size="sm" c="dimmed">
{meta}
{pageCount && dateText && (
<>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{pageCount} {t('compare.pages', 'pages')}
<br />
{dateText}
</Text>
)}
</Stack>
</Group>
</Box>
</>
)}
</Stack>
</Group>
</Box>
</Stack>
);
},
[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: (
<Stack gap="md">
{renderSelectedFile('base')}
{renderSelectedFile('comparison')}
<Group justify="flex-start">
<Box
style={{
display: 'grid',
gridTemplateColumns: hasBothSelected ? '1fr 2.25rem' : '1fr',
gap: '1rem',
alignItems: 'stretch',
width: '100%',
}}
>
<Box
style={{
gridColumn: '1',
minWidth: 0,
}}
>
{renderSelectedFile('base')}
<div style={{ height: '0.75rem' }} />
{renderSelectedFile('comparison')}
</Box>
{hasBothSelected && (
<Box
style={{
gridColumn: '2',
gridRow: '1',
display: 'flex',
alignItems: 'stretch',
justifyContent: 'center',
alignSelf: 'stretch',
marginTop: '1.5rem',
}}
>
<Button
variant="outline"
variant="subtle"
onClick={handleSwap}
disabled={!params.baseFileId || !params.comparisonFileId || base.operation.isLoading}
disabled={!hasBothSelected || base.operation.isLoading}
style={{
width: '2.25rem',
height: '100%',
padding: 0,
borderRadius: '0.5rem',
background: 'var(--bg-surface)',
border: '1px solid var(--border-default)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{t('compare.swap', 'Swap PDFs')}
<SwapVertRoundedIcon fontSize="medium" />
</Button>
</Group>
</Stack>
</Box>
)}
<Modal
opened={swapConfirmOpen}
onClose={() => setSwapConfirmOpen(false)}
title={t('compare.swap.confirmTitle', 'Re-run comparison?')}
centered
size="sm"
>
<Stack gap="md">
<Text>{t('compare.swap.confirmBody', 'This will rerun the tool. Are you sure you want to swap the order of Original and Edited?')}</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={() => setSwapConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
<Button
variant="filled"
onClick={() => {
setSwapConfirmOpen(false);
performSwap();
}}
>
{t('compare.swap.confirm', 'Swap and Re-run')}
</Button>
</Group>
</Stack>
</Modal>
</Box>
),
},
],

View File

@ -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;

View File

@ -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;