mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
tranlation files, remove unused file/functions/styles, fix buggy behaviour
This commit is contained in:
parent
f1e3c93890
commit
65a30aece7
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
<span>{placeholder}</span>
|
||||
<span className="compare-changes-select__placeholder">{placeholder}</span>
|
||||
<Combobox.Chevron />
|
||||
</div>
|
||||
</Combobox.Target>
|
||||
|
||||
@ -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;
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.'
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user