addition of the compare tool

This commit is contained in:
EthanHealy01 2025-10-22 01:08:41 +01:00
parent 5354f08766
commit baa662f3ff
22 changed files with 3766 additions and 24 deletions

View File

@ -352,6 +352,11 @@
"refreshPage": "Refresh Page"
},
"home": {
"compare": {
"tags": "difference,changes,review",
"title": "Compare",
"desc": "Compare two PDF documents and highlight differences"
},
"desc": "Your locally hosted one-stop-shop for all your PDF needs.",
"searchBar": "Search for features...",
"viewPdf": {
@ -1276,15 +1281,61 @@
"tags": "differentiate,contrast,changes,analysis",
"title": "Compare",
"header": "Compare PDFs",
"highlightColor": {
"1": "Highlight Color 1:",
"2": "Highlight Color 2:"
"description": "Select the base and comparison PDF to highlight differences.",
"view": {
"title": "Compare view",
"noData": "Run a comparison to view the summary and diff."
},
"document": {
"1": "Document 1",
"2": "Document 2"
"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"
},
"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"
},
"summary": {
"baseHeading": "Base document",
"comparisonHeading": "Comparison document",
"pageLabel": "Page"
},
"status": {
"extracting": "Extracting text...",
"processing": "Analyzing differences...",
"complete": "Comparison ready"
},
"error": {
"selectRequired": "Select a base and comparison document.",
"filesMissing": "Unable to locate the selected files. Please re-select them.",
"generic": "Unable to compare these files."
},
"submit": "Compare",
"complex": {
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
},
@ -1297,7 +1348,15 @@
"text": {
"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."
},
"certSign": {
"tags": "authenticate,PEM,P12,official,encrypt",

View File

@ -79,6 +79,7 @@ export default function Workbench() {
switch (currentView) {
case "fileEditor":
return (
<FileEditor
toolMode={!!selectedToolId}
@ -96,6 +97,7 @@ export default function Workbench() {
);
case "viewer":
return (
<Viewer
sidebarsVisible={sidebarsVisible}
@ -108,6 +110,7 @@ export default function Workbench() {
);
case "pageEditor":
return (
<>
<PageEditor
@ -141,6 +144,8 @@ export default function Workbench() {
default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
@ -152,7 +157,7 @@ export default function Workbench() {
return (
<Box
className="flex-1 h-full min-w-80 relative flex flex-col"
className="flex-1 h-full min-w-0 relative flex flex-col"
style={
isRainbowMode
? {} // No background color in rainbow mode

View File

@ -31,6 +31,13 @@
flex-direction: column-reverse;
}
.toast-container--bottom-center {
bottom: 16px;
left: 50%;
transform: translateX(-50%);
flex-direction: column-reverse;
}
/* Toast Item Styles */
.toast-item {
min-width: 320px;

View File

@ -8,6 +8,7 @@ const locationToClass: Record<ToastLocation, string> = {
'top-right': 'toast-container--top-right',
'bottom-left': 'toast-container--bottom-left',
'bottom-right': 'toast-container--bottom-right',
'bottom-center': 'toast-container--bottom-center',
};
function getToastItemClass(t: ToastInstance): string {
@ -44,7 +45,7 @@ export default function ToastRenderer() {
if (!acc[key]) acc[key] = [] as ToastInstance[];
acc[key].push(t);
return acc;
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] });
return (
<>

View File

@ -1,6 +1,6 @@
import { ReactNode } from 'react';
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'bottom-center';
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
export interface ToastOptions {

View File

@ -0,0 +1,50 @@
import { Button, Group, Stack, Text } from '@mantine/core';
import SwapHorizRoundedIcon from '@mui/icons-material/SwapHorizRounded';
import DownloadRoundedIcon from '@mui/icons-material/DownloadRounded';
import { useTranslation } from 'react-i18next';
interface CompareReviewActionsProps {
onSwitchOrder: () => void;
onDownloadSummary: () => void;
disableDownload?: boolean;
disableSwitch?: boolean;
}
const CompareReviewActions = ({
onSwitchOrder,
onDownloadSummary,
disableDownload = false,
disableSwitch = false,
}: CompareReviewActionsProps) => {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t('compare.review.actionsHint', 'Review the comparison, switch document roles, or export the summary.')}
</Text>
<Group grow>
<Button
variant="outline"
color="var(--mantine-color-gray-6)"
leftSection={<SwapHorizRoundedIcon fontSize="small" />}
onClick={onSwitchOrder}
disabled={disableSwitch}
>
{t('compare.review.switchOrder', 'Switch order')}
</Button>
<Button
color="blue"
leftSection={<DownloadRoundedIcon fontSize="small" />}
onClick={onDownloadSummary}
disabled={disableDownload}
>
{t('compare.review.exportSummary', 'Export summary')}
</Button>
</Group>
</Stack>
);
};
export default CompareReviewActions;

View File

@ -0,0 +1,105 @@
import { useMemo } from 'react';
import { Badge, Card, Group, Select, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAllFiles } from '../../../contexts/FileContext';
import { formatFileSize } from '../../../utils/fileUtils';
import type { FileId } from '../../../types/file';
interface CompareSelectionStepProps {
role: 'base' | 'comparison';
selectedFileId: FileId | null;
onFileSelect: (fileId: FileId | null) => void;
disabled?: boolean;
}
export const CompareSelectionStep = ({
role,
selectedFileId,
onFileSelect,
disabled = false,
}: CompareSelectionStepProps) => {
const { t } = useTranslation();
const { fileStubs } = useAllFiles();
const labels = useMemo(() => {
if (role === 'base') {
return {
title: t('compare.base.label', 'Base document'),
placeholder: t('compare.base.placeholder', 'Select a base PDF'),
};
}
return {
title: t('compare.comparison.label', 'Comparison document'),
placeholder: t('compare.comparison.placeholder', 'Select a comparison PDF'),
};
}, [role, t]);
const options = useMemo(() => {
return fileStubs
.filter((stub) => stub.type?.includes('pdf') || stub.name.toLowerCase().endsWith('.pdf'))
.map((stub) => ({
value: stub.id as unknown as string,
label: stub.name,
}));
}, [fileStubs]);
const selectedStub = useMemo(() => fileStubs.find((stub) => stub.id === selectedFileId), [fileStubs, selectedFileId]);
const selectValue = selectedFileId ? (selectedFileId as unknown as string) : null;
// Hide dropdown until there are files in the workbench
if (options.length === 0) {
return (
<Card withBorder padding="sm" radius="md">
<Text size="sm" c="dimmed">
{t('compare.addFilesHint', 'Add PDFs in the Files step to enable selection.')}
</Text>
</Card>
);
}
return (
<Stack gap="sm">
<Select
data={options}
searchable
clearable
value={selectValue}
label={labels.title}
placeholder={labels.placeholder}
onChange={(value) => onFileSelect(value ? (value as FileId) : null)}
nothingFoundMessage={t('compare.noFiles', 'No PDFs available yet')}
disabled={disabled}
/>
{selectedStub && (
<Card withBorder padding="sm" radius="md">
<Stack gap={4}>
<Text fw={600} size="sm">
{selectedStub.name}
</Text>
<Group gap="xs">
<Badge color="blue" variant="light">
{formatFileSize(selectedStub.size ?? 0)}
</Badge>
{selectedStub.processedFile?.totalPages && (
<Badge color="gray" variant="light">
{t('compare.pageCount', '{{count}} pages', { count: selectedStub.processedFile.totalPages })}
</Badge>
)}
</Group>
{selectedStub.lastModified && (
<Text size="xs" c="dimmed">
{t('compare.lastModified', 'Last modified')}{' '}
{new Date(selectedStub.lastModified).toLocaleString()}
</Text>
)}
</Stack>
</Card>
)}
</Stack>
);
};
export default CompareSelectionStep;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,411 @@
.compare-workbench {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
height: 100%;
min-height: 0;
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
min-width: 0;
}
.compare-workbench__mode {
align-self: center;
}
.compare-workbench__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: start;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
grid-auto-rows: 1fr;
}
/* Stacked mode: two rows with synchronized scroll panes */
.compare-workbench__columns--stacked {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.compare-workbench__columns > div {
/* Critical for responsive flex children inside non-wrapping layouts */
min-height: 0;
min-width: 0;
}
.compare-legend {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Sticky header styling overrides */
.compare-header {
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--mantine-color-gray-3);
padding: 0.5rem;
margin: -0.5rem -0.5rem 0.5rem -0.5rem;
}
/* Dropdown badge-like style - only style the dropdowns, not titles */
.compare-changes-select {
background: rgba(255, 59, 48, 0.15) !important;
color: #b91c1c !important;
border: none !important;
border-radius: 8px !important;
font-weight: 500 !important;
}
.compare-changes-select--comparison {
background: rgba(52, 199, 89, 0.18) !important;
color: #1b5e20 !important;
border: none !important;
border-radius: 8px !important;
font-weight: 500 !important;
}
/* Style the dropdown container */
.compare-changes-select .mantine-Combobox-dropdown {
border: 1px solid var(--mantine-color-gray-3) !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
.compare-changes-select--comparison .mantine-Combobox-dropdown {
border: 1px solid var(--mantine-color-gray-3) !important;
border-radius: 8px !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
/* Custom scrollbar for ScrollArea */
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar {
width: 6px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
background: var(--mantine-color-gray-1) !important;
border-radius: 3px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
background: var(--mantine-color-gray-4) !important;
border-radius: 3px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
background: var(--mantine-color-gray-5) !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar {
width: 6px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
background: var(--mantine-color-gray-1) !important;
border-radius: 3px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
background: var(--mantine-color-gray-4) !important;
border-radius: 3px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
background: var(--mantine-color-gray-5) !important;
}
/* Style the dropdown options */
.compare-changes-select .mantine-Combobox-option {
font-size: 0.875rem !important;
padding: 8px 12px !important;
}
.compare-changes-select--comparison .mantine-Combobox-option {
font-size: 0.875rem !important;
padding: 8px 12px !important;
}
.compare-changes-select .mantine-Combobox-option:hover {
background-color: rgba(255, 59, 48, 0.1) !important;
}
.compare-changes-select--comparison .mantine-Combobox-option:hover {
background-color: rgba(52, 199, 89, 0.15) !important;
}
/* Style the search input */
.compare-changes-select .mantine-Combobox-search {
font-size: 0.875rem !important;
padding: 8px 12px !important;
border-bottom: 1px solid var(--mantine-color-gray-3) !important;
}
.compare-changes-select--comparison .mantine-Combobox-search {
font-size: 0.875rem !important;
padding: 8px 12px !important;
border-bottom: 1px solid var(--mantine-color-gray-3) !important;
}
.compare-changes-select .mantine-Combobox-search::placeholder {
color: var(--mantine-color-gray-5) !important;
}
.compare-changes-select--comparison .mantine-Combobox-search::placeholder {
color: var(--mantine-color-gray-5) !important;
}
/* Style the chevron - ensure proper coloring */
.compare-changes-select .mantine-Combobox-chevron {
color: inherit !important;
}
.compare-changes-select--comparison .mantine-Combobox-chevron {
color: inherit !important;
}
/* Flash/pulse highlight for navigated change */
@keyframes compare-flash {
0% {
outline: 4px solid rgba(255, 235, 59, 0.0);
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
background-color: rgba(255, 235, 59, 0.2) !important;
}
25% {
outline: 4px solid rgba(255, 235, 59, 1.0);
box-shadow: 0 0 20px rgba(255, 235, 59, 0.8);
background-color: rgba(255, 235, 59, 0.4) !important;
}
50% {
outline: 4px solid rgba(255, 235, 59, 1.0);
box-shadow: 0 0 30px rgba(255, 235, 59, 0.9);
background-color: rgba(255, 235, 59, 0.5) !important;
}
75% {
outline: 4px solid rgba(255, 235, 59, 0.8);
box-shadow: 0 0 15px rgba(255, 235, 59, 0.6);
background-color: rgba(255, 235, 59, 0.3) !important;
}
100% {
outline: 4px solid rgba(255, 235, 59, 0.0);
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
background-color: rgba(255, 235, 59, 0.0) !important;
}
}
.compare-diff-highlight--flash {
animation: compare-flash 1.5s ease-in-out 1;
z-index: 1000;
position: relative;
/* Bonus: temporarily override red/green to yellow during flash for clarity */
background-color: rgba(255, 235, 59, 0.5) !important;
}
.compare-legend__item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--mantine-color-dimmed);
}
.compare-legend__swatch {
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.15);
}
.compare-summary__stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.compare-summary__stat-card {
flex: 1;
min-width: 12rem;
}
.compare-summary__segment {
border: 1px solid var(--mantine-color-gray-3);
border-radius: 0.5rem;
padding: 0.75rem;
background-color: var(--mantine-color-gray-0);
}
.compare-diff-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.compare-diff-page__canvas {
position: relative;
border: 1px solid var(--mantine-color-gray-4);
border-radius: 0.75rem;
overflow: hidden;
background-color: var(--mantine-color-white);
width: 100%;
}
/* Ensure the image scales to the container width without overflowing (handles rotation/landscape) */
.compare-diff-page__canvas img {
display: block;
max-width: 100%;
width: 100%;
height: auto;
object-fit: contain;
}
/* Ensure inner page wrapper centers and scales within container */
.compare-diff-page__canvas > div {
margin-left: auto;
margin-right: auto;
max-width: 100%;
}
.compare-diff-page__canvas img {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.compare-diff-highlight {
position: absolute;
pointer-events: none;
mix-blend-mode: multiply;
}
/* Inline paragraph highlights in summary */
.compare-inline {
border-radius: 0.2rem;
padding: 0.05rem 0.15rem;
}
.compare-inline--removed {
background-color: rgba(255, 59, 48, 0.25);
}
.compare-inline--added {
background-color: rgba(52, 199, 89, 0.25);
}
.compare-workbench--upload {
padding: 2.5rem 1.5rem;
}
.compare-pane-header {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-background);
padding: 0.25rem 0;
}
.compare-upload-layout {
display: flex;
align-items: stretch;
gap: 2rem;
width: 100%;
}
.compare-upload-divider {
width: 1px;
background: rgba(148, 163, 184, 0.5);
}
.compare-upload-column {
flex: 1;
display: flex;
}
.compare-upload-dropzone {
flex: 1;
border: 1px dashed rgba(148, 163, 184, 0.6);
border-radius: 1rem;
background: rgba(241, 245, 249, 0.45);
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: rgba(17, 24, 39, 0.75);
background: rgba(148, 163, 184, 0.2);
}
.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;
}
@media (max-width: 960px) {
.compare-upload-layout {
flex-direction: column;
}
.compare-upload-divider {
display: none;
}
.compare-upload-card {
min-height: 18rem;
}
}

View File

@ -86,7 +86,7 @@ const FileStatusIndicator = ({
<Text size="sm" c="dimmed">
<Anchor
size="sm"
onClick={() => openFilesModal()}
onClick={() => openFilesModal({})}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
@ -121,7 +121,7 @@ const FileStatusIndicator = ({
{getPlaceholder() + " "}
<Anchor
size="sm"
onClick={() => openFilesModal()}
onClick={() => openFilesModal({})}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />

View File

@ -62,8 +62,8 @@ export function useViewerRightRailButtons() {
render: ({ disabled }) => (
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanning ? 'filled' : 'subtle'}
color={isPanning ? 'blue' : undefined}
variant={isPanning ? 'default' : 'subtle'}
color={undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
@ -71,6 +71,7 @@ export function useViewerRightRailButtons() {
setIsPanning(prev => !prev);
}}
disabled={disabled}
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>

View File

@ -6,7 +6,7 @@ import { fileStorage } from '../services/fileStorage';
interface FilesModalContextType {
isFilesModalOpen: boolean;
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void; maxNumberOfFiles?: number }) => void;
closeFilesModal: () => void;
onFileUpload: (files: File[]) => void;
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
@ -24,7 +24,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => {
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void; maxNumberOfFiles?: number }) => {
setInsertAfterPage(options?.insertAfterPage);
setCustomHandler(() => options?.customHandler);
setIsFilesModalOpen(true);

View File

@ -222,10 +222,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
if (isBaseWorkbench(navigationState.workbench)) {
return;
}
// Keep guard lightweight; remove verbose logging
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
if (!currentCustomView || currentCustomView.data == null) {
actions.setWorkbench(getDefaultWorkbench());
return;
}
}, [actions, customWorkbenchViews, navigationState.workbench]);

View File

@ -109,6 +109,7 @@ import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksS
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSettings";
import ValidateSignature from "../tools/ValidateSignature";
import Compare from "../tools/Compare";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@ -751,13 +752,15 @@ export function useFlatToolRegistry(): ToolRegistry {
compare: {
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.compare.title", "Compare"),
component: null,
component: Compare,
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: 2,
operationConfig: undefined,
automationSettings: null,
synonyms: getSynonyms(t, "compare"),
supportsAutomate: false,
automationSettings: null
supportsAutomate: false
},
compress: {
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -0,0 +1,833 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { appendWord as sharedAppendWord } from '../../../utils/textDiff';
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
import {
ADDITION_HIGHLIGHT,
CompareChange,
CompareDiffToken,
CompareResultData,
CompareWorkerRequest,
CompareWorkerResponse,
CompareWorkerWarnings,
REMOVAL_HIGHLIGHT,
PARAGRAPH_SENTINEL,
} from '../../../types/compare';
import { CompareParameters } from './useCompareParameters';
import { ToolOperationHook } from '../shared/useToolOperation';
import type { StirlingFile } from '../../../types/fileContext';
import { useFileContext } from '../../../contexts/file/fileHooks';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import type { TokenBoundingBox } from '../../../types/compare';
import type { CompareParagraph } from '../../../types/compare';
interface TokenMetadata {
page: number;
paragraph: number;
bbox: TokenBoundingBox | null;
}
interface ExtractedContent {
tokens: string[];
metadata: TokenMetadata[];
pageSizes: { width: number; height: number }[];
paragraphs: CompareParagraph[];
}
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
let lastMeasurementFont = '';
const DEFAULT_CHAR_WIDTH = 1;
const DEFAULT_SPACE_WIDTH = 0.33;
const measureTextWidth = (fontSpec: string, text: string): number => {
if (!measurementContext) {
if (!text) return 0;
if (text === ' ') return DEFAULT_SPACE_WIDTH;
return text.length * DEFAULT_CHAR_WIDTH;
}
if (lastMeasurementFont !== fontSpec) {
measurementContext.font = fontSpec;
lastMeasurementFont = fontSpec;
}
const key = `${fontSpec}|${text}`;
const cached = textMeasurementCache?.get(key);
if (cached !== undefined) {
return cached;
}
const width = measurementContext.measureText(text).width || 0;
textMeasurementCache?.set(key, width);
return width;
};
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
result: CompareResultData | null;
warnings: string[];
}
const DEFAULT_WORKER_SETTINGS = {
batchSize: 6000,
complexThreshold: 120000,
maxWordThreshold: 200000,
};
const aggregateTotals = (tokens: CompareDiffToken[]) => {
return tokens.reduce(
(totals, token) => {
if (token.text === PARAGRAPH_SENTINEL) {
return totals;
}
switch (token.type) {
case 'added':
totals.added += 1;
break;
case 'removed':
totals.removed += 1;
break;
default:
totals.unchanged += 1;
}
return totals;
},
{ added: 0, removed: 0, unchanged: 0 }
);
};
const shouldConcatWithoutSpace = (word: string) => {
return /^[.,!?;:)\]\}]/.test(word) || word.startsWith("'") || word === "'s";
};
const appendWord = (existing: string, word: string) => {
if (!existing) {
return sharedAppendWord('', word);
}
return sharedAppendWord(existing, word);
};
const buildChanges = (
tokens: CompareDiffToken[],
baseMetadata: TokenMetadata[],
comparisonMetadata: TokenMetadata[]
): CompareChange[] => {
const changes: CompareChange[] = [];
let baseIndex = 0;
let comparisonIndex = 0;
let current: CompareChange | null = null;
let currentBaseParagraph: number | null = null;
let currentComparisonParagraph: number | null = null;
const ensureCurrent = (): CompareChange => {
if (!current) {
current = {
id: `change-${changes.length}`,
base: null,
comparison: null,
};
}
return current;
};
const flush = () => {
if (current) {
if (current.base) {
current.base.text = current.base.text.trim();
}
if (current.comparison) {
current.comparison.text = current.comparison.text.trim();
}
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
changes.push(current);
}
}
current = null;
currentBaseParagraph = null;
currentComparisonParagraph = null;
};
for (const token of tokens) {
// Treat paragraph sentinels as hard boundaries, not visible changes
if (token.text === PARAGRAPH_SENTINEL) {
if (token.type === 'removed' && baseIndex < baseMetadata.length) {
baseIndex += 1;
}
if (token.type === 'added' && comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
flush();
continue;
}
if (token.type === 'removed') {
const meta = baseMetadata[baseIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.base) {
active.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentBaseParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentBaseParagraph !== null &&
paragraph !== currentBaseParagraph &&
active.base.text.trim().length > 0
) {
// Start a new change for a new paragraph to avoid ballooning
flush();
const next = ensureCurrent();
next.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.base.text = appendWord(active.base.text, token.text);
}
if (meta && active.base.page === null) {
active.base.page = meta.page;
}
if (meta && active.base.paragraph === null) {
active.base.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentBaseParagraph = paragraph;
}
}
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
continue;
}
if (token.type === 'added') {
const meta = comparisonMetadata[comparisonIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.comparison) {
active.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentComparisonParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentComparisonParagraph !== null &&
paragraph !== currentComparisonParagraph &&
active.comparison.text.trim().length > 0
) {
// Start a new change for a new paragraph to avoid ballooning
flush();
const next = ensureCurrent();
next.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.comparison.text = appendWord(active.comparison.text, token.text);
}
if (meta && active.comparison.page === null) {
active.comparison.page = meta.page;
}
if (meta && active.comparison.paragraph === null) {
active.comparison.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentComparisonParagraph = paragraph;
}
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
continue;
}
// unchanged token
flush();
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
}
flush();
return changes;
};
const createSummaryFile = (result: CompareResultData): File => {
const exportPayload = {
generatedAt: new Date(result.totals.processedAt).toISOString(),
base: {
name: result.base.fileName,
totalWords: result.base.wordCount,
},
comparison: {
name: result.comparison.fileName,
totalWords: result.comparison.wordCount,
},
totals: {
added: result.totals.added,
removed: result.totals.removed,
unchanged: result.totals.unchanged,
durationMs: result.totals.durationMs,
},
changes: result.changes.map((change) => ({
base: change.base,
comparison: change.comparison,
})),
warnings: result.warnings,
};
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
};
const clamp = (value: number): number => Math.min(1, Math.max(0, value));
const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const tokens: string[] = [];
const metadata: TokenMetadata[] = [];
const pageSizes: { width: number; height: number }[] = [];
const paragraphs: CompareParagraph[] = [];
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
const page = await pdfDoc.getPage(pageIndex);
const viewport = page.getViewport({ scale: 1 });
// pdf.js typings may not include disableCombineTextItems; pass via any
const content = await (page as any).getTextContent({ disableCombineTextItems: true });
const styles: Record<string, { fontFamily?: string }> = ((content as any).styles ?? {}) as Record<
string,
{ fontFamily?: string }
>;
let paragraphIndex = 1;
let paragraphBuffer = '';
let prevItem: TextItem | null = null;
pageSizes.push({ width: viewport.width, height: viewport.height });
const normalizeToken = (s: string) =>
s
.normalize('NFKC')
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
.replace(/[“”]/g, '"')
.replace(/[]/g, "'")
.replace(/[–—]/g, '-')
.replace(/\s+/g, ' ')
.trim();
const normalizeAndSplit = (raw: string) => {
const norm = normalizeToken(raw);
const parts = norm.match(/[A-Za-z0-9]+|[^\sA-Za-z0-9]/g) || [];
return parts.filter(Boolean);
};
const isParagraphBreak = (curr: TextItem, prev: TextItem | null, yJumpThreshold = 6) => {
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
if (hasHardBreak) return true;
if (!prev) return false;
const prevY = prev.transform[5];
const currY = curr.transform[5];
return Math.abs(currY - prevY) > yJumpThreshold;
};
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
if (width <= 0 || height <= 0) {
return null;
}
const MIN_WIDTH = 0.004; // ensure very short tokens still get a visible highlight
const MIN_HORIZONTAL_PAD = 0.0012;
const HORIZONTAL_PAD_RATIO = 0.12;
const MIN_VERTICAL_PAD = 0.0008;
const VERTICAL_PAD_RATIO = 0.18;
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
let expandedLeft = left - horizontalPad;
let expandedRight = left + width + horizontalPad;
let expandedTop = top - verticalPad;
let expandedBottom = top + height + verticalPad;
if (expandedRight - expandedLeft < MIN_WIDTH) {
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
expandedLeft -= deficit / 2;
expandedRight += deficit / 2;
}
expandedLeft = clamp(expandedLeft);
expandedRight = clamp(expandedRight);
expandedTop = clamp(expandedTop);
expandedBottom = clamp(expandedBottom);
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
return null;
}
return {
left: expandedLeft,
top: expandedTop,
width: expandedRight - expandedLeft,
height: expandedBottom - expandedTop,
};
};
for (const item of content.items as TextItem[]) {
if (!item?.str) {
prevItem = null;
continue;
}
// Compute a per-word bounding box within this TextItem by proportionally
// subdividing the item's horizontal span based on character weights
// (simple glyph-width heuristic) rather than naive character counts.
const rawText = item.str;
const totalLen = Math.max(rawText.length, 1);
const styles: any = (content as any).styles || {};
const textStyle = styles[item.fontName] as { fontFamily?: string } | undefined;
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
const fontSpec = `${fontScale}px ${fontFamily}`;
const weights: number[] = new Array(totalLen);
let runningText = '';
let previousAdvance = 0;
for (let i = 0; i < totalLen; i += 1) {
runningText += rawText[i];
const advance = measureTextWidth(fontSpec, runningText);
let width = advance - previousAdvance;
if (!Number.isFinite(width) || width <= 0) {
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
weights[i] = width;
previousAdvance = advance;
}
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
for (let i = 0; i < totalLen; i += 1) {
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
}
const prefix: number[] = new Array(totalLen + 1);
prefix[0] = 0;
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
const totalWeight = prefix[totalLen] || 1;
const [rawX, rawY] = [item.transform[4], item.transform[5]];
const [x1, y1] = viewport.convertToViewportPoint(rawX, rawY);
const [x2, y2] = viewport.convertToViewportPoint(rawX + item.width, rawY + item.height);
const left = Math.min(x1, x2);
const right = Math.max(x1, x2);
const top = Math.min(y1, y2);
const bottom = Math.max(y1, y2);
let normalizedTop = clamp(top / viewport.height);
let normalizedBottom = clamp(bottom / viewport.height);
let height = Math.max(normalizedBottom - normalizedTop, 0);
// Tighten vertical box using font ascent/descent when available
const fontName: string | undefined = (item as any).fontName;
const font = fontName ? styles[fontName] : undefined;
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
const vFactor = Math.min(1, Math.max(0.75, ascent + descent));
const shrink = height * (1 - vFactor);
if (shrink > 0) {
normalizedTop += shrink / 2;
height = height * vFactor;
normalizedBottom = normalizedTop + height;
}
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(rawText)) !== null) {
const wordRaw = match[0];
const normalizedWord = normalizeToken(wordRaw);
if (!normalizedWord) {
continue;
}
const startIndex = match.index;
const endIndex = startIndex + wordRaw.length;
const relStart = prefix[startIndex] / totalWeight;
const relEnd = prefix[endIndex] / totalWeight;
const segLeft = left + (right - left) * relStart;
const segRight = left + (right - left) * relEnd;
const normalizedLeft = clamp(Math.min(segLeft, segRight) / viewport.width);
const normalizedRight = clamp(Math.max(segLeft, segRight) / viewport.width);
const width = Math.max(normalizedRight - normalizedLeft, 0);
const bbox = adjustBoundingBox(normalizedLeft, normalizedTop, width, height);
tokens.push(normalizedWord);
metadata.push({
page: pageIndex,
paragraph: paragraphIndex,
bbox,
});
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
}
if (isParagraphBreak(item, prevItem)) {
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
}
tokens.push(PARAGRAPH_SENTINEL);
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
paragraphIndex += 1;
}
prevItem = item;
}
// Flush any dangling paragraph at end of page
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
tokens.push(PARAGRAPH_SENTINEL);
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
}
}
return { tokens, metadata, pageSizes, paragraphs };
} finally {
pdfWorkerManager.destroyDocument(pdfDoc);
}
};
export const useCompareOperation = (): CompareOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const workerRef = useRef<Worker | null>(null);
const previousUrl = useRef<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [result, setResult] = useState<CompareResultData | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const ensureWorker = useCallback(() => {
if (!workerRef.current) {
workerRef.current = new Worker(
new URL('../../../workers/compareWorker.ts', import.meta.url),
{ type: 'module' }
);
}
return workerRef.current;
}, []);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
setResult(null);
setWarnings([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatus('');
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const runCompareWorker = useCallback(
async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings) => {
const worker = ensureWorker();
return await new Promise<{
tokens: CompareDiffToken[];
stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number };
warnings: string[];
}>((resolve, reject) => {
const collectedWarnings: string[] = [];
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
const message = event.data;
if (!message) {
return;
}
switch (message.type) {
case 'success':
cleanup();
resolve({
tokens: message.tokens,
stats: message.stats,
warnings: collectedWarnings,
});
break;
case 'warning':
collectedWarnings.push(message.message);
break;
case 'error': {
cleanup();
const error = new Error(message.message);
(error as any).code = message.code;
reject(error);
break;
}
default:
break;
}
};
const handleError = (event: ErrorEvent) => {
cleanup();
reject(event.error ?? new Error(event.message));
};
const cleanup = () => {
worker.removeEventListener('message', handleMessage as EventListener);
worker.removeEventListener('error', handleError as EventListener);
};
worker.addEventListener('message', handleMessage as EventListener);
worker.addEventListener('error', handleError as EventListener);
const request: CompareWorkerRequest = {
type: 'compare',
payload: {
baseTokens,
comparisonTokens,
warnings: warningMessages,
settings: DEFAULT_WORKER_SETTINGS,
},
};
worker.postMessage(request);
});
},
[ensureWorker]
);
const executeOperation = useCallback(
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
if (!params.baseFileId || !params.comparisonFileId) {
setErrorMessage(t('compare.error.selectRequired', 'Select a base and comparison document.'));
return;
}
const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId)
?? selectors.getFile(params.baseFileId);
const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId)
?? selectors.getFile(params.comparisonFileId);
if (!baseFile || !comparisonFile) {
setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.'));
return;
}
setIsLoading(true);
setStatus(t('compare.status.extracting', 'Extracting text...'));
setErrorMessage(null);
setWarnings([]);
setResult(null);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
const warningMessages: CompareWorkerWarnings = {
complexMessage: t(
'compare.complex.message',
'One or both of the provided documents are large files, accuracy of comparison may be reduced'
),
tooLargeMessage: t(
'compare.large.file.message',
'One or Both of the provided documents are too large to process'
),
emptyTextMessage: t(
'compare.no.text.message',
'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.'
),
};
const operationStart = performance.now();
try {
const [baseContent, comparisonContent] = await Promise.all([
extractContentFromPdf(baseFile),
extractContentFromPdf(comparisonFile),
]);
if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) {
throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' });
}
setStatus(t('compare.status.processing', 'Analyzing differences...'));
const { tokens, stats, warnings: workerWarnings } = await runCompareWorker(
baseContent.tokens,
comparisonContent.tokens,
warningMessages
);
const totals = aggregateTotals(tokens);
const processedAt = Date.now();
const baseMetadata = baseContent.metadata;
const comparisonMetadata = comparisonContent.metadata;
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
const comparisonResult: CompareResultData = {
base: {
fileId: baseFile.fileId,
fileName: baseFile.name,
highlightColor: REMOVAL_HIGHLIGHT,
wordCount: stats.baseWordCount,
pageSizes: baseContent.pageSizes,
},
comparison: {
fileId: comparisonFile.fileId,
fileName: comparisonFile.name,
highlightColor: ADDITION_HIGHLIGHT,
wordCount: stats.comparisonWordCount,
pageSizes: comparisonContent.pageSizes,
},
totals: {
...totals,
durationMs: stats.durationMs,
processedAt,
},
tokens,
tokenMetadata: {
base: baseMetadata,
comparison: comparisonMetadata,
},
sourceTokens: {
base: baseContent.tokens,
comparison: comparisonContent.tokens,
},
changes,
warnings: workerWarnings,
baseParagraphs: baseContent.paragraphs,
comparisonParagraphs: comparisonContent.paragraphs,
};
setResult(comparisonResult);
setWarnings(workerWarnings);
const summaryFile = createSummaryFile(comparisonResult);
setFiles([summaryFile]);
cleanupDownloadUrl();
const blobUrl = URL.createObjectURL(summaryFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
setDownloadFilename(summaryFile.name);
setStatus(t('compare.status.complete', 'Comparison ready'));
} catch (error: any) {
console.error('[compare] operation failed', error);
if (error?.code === 'TOO_LARGE') {
setErrorMessage(warningMessages.tooLargeMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
} else if (error?.code === 'EMPTY_TEXT') {
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
} else {
setErrorMessage(
error instanceof Error
? error.message
: t('compare.error.generic', 'Unable to compare these files.')
);
}
} finally {
const duration = performance.now() - operationStart;
setStatus((prev) => (prev ? `${prev} (${Math.round(duration)} ms)` : prev));
setIsLoading(false);
}
},
[cleanupDownloadUrl, runCompareWorker, selectors, t]
);
const cancelOperation = useCallback(() => {
if (isLoading) {
setIsLoading(false);
setStatus(t('operationCancelled', 'Operation cancelled'));
}
}, [isLoading, t]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
};
}, [cleanupDownloadUrl]);
return useMemo<CompareOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
result,
warnings,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
result,
status,
undoOperation,
warnings,
]
);
};

View File

@ -0,0 +1,23 @@
import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters';
import type { FileId } from '../../../types/file';
export interface CompareParameters {
baseFileId: FileId | null;
comparisonFileId: FileId | null;
}
export const defaultParameters: CompareParameters = {
baseFileId: null,
comparisonFileId: null,
};
export type CompareParametersHook = BaseParametersHook<CompareParameters>;
export const useCompareParameters = (): CompareParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'compare',
validateFn: (params) =>
Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId),
});
};

View File

@ -8,7 +8,7 @@ export const useFileHandler = () => {
// Merge default options with passed options - passed options take precedence
const mergedOptions = { selectFiles: true, ...options };
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles(files, mergedOptions);
return await actions.addFiles(files, mergedOptions);
}, [actions.addFiles]);
return {

View File

@ -174,6 +174,7 @@
--right-rail-foreground: #E3E4E5; /* panel behind custom tool icons */
--right-rail-icon: #4B5563; /* icon color */
--right-rail-icon-disabled: #CECECE;/* disabled icon */
--right-rail-pan-active-bg: #EAEAEA;
/* Colors for tooltips */
--tooltip-title-bg: #DBEFFF;
@ -415,6 +416,7 @@
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
--right-rail-icon: #BCBEBF; /* icon color */
--right-rail-icon-disabled: #43464B;/* disabled icon */
--right-rail-pan-active-bg: #EAEAEA;
/* Dark mode tooltip colors */
--tooltip-title-bg: #4B525A;

View File

@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
import { Box, Card, Group, Stack, Text, Button } from '@mantine/core';
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
import { BaseToolProps, ToolComponent } from '../types/tool';
import {
useCompareParameters,
defaultParameters as compareDefaultParameters,
} from '../hooks/tools/compare/useCompareParameters';
import {
useCompareOperation,
CompareOperationHook,
} from '../hooks/tools/compare/useCompareOperation';
import CompareWorkbenchView from '../components/tools/compare/CompareWorkbenchView';
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
import { useFileActions, useFileContext } from '../contexts/file/fileHooks';
import type { FileId } from '../types/file';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import DocumentThumbnail from '../components/shared/filePreview/DocumentThumbnail';
import { useFilesModalContext } from '../contexts/FilesModalContext';
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
const Compare = (props: BaseToolProps) => {
const { t } = useTranslation();
const { actions: navigationActions } = useNavigationActions();
const navigationState = useNavigationState();
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const { selectors } = useFileContext();
const { actions: fileActions } = useFileActions();
const { openFilesModal } = useFilesModalContext();
const base = useBaseTool(
'compare',
useCompareParameters,
useCompareOperation,
props,
{ minFiles: 2 }
);
const operation = base.operation as CompareOperationHook;
const params = base.params.parameters;
const compareIcon = useMemo(() => <CompareRoundedIcon fontSize="small" />, []);
useEffect(() => {
registerCustomWorkbenchView({
id: CUSTOM_VIEW_ID,
workbenchId: CUSTOM_WORKBENCH_ID,
// Use a static label at registration time to avoid re-registering on i18n changes
label: 'Compare view',
icon: compareIcon,
component: CompareWorkbenchView,
});
return () => {
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
unregisterCustomWorkbenchView(CUSTOM_VIEW_ID);
};
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
}, []);
// Map the first two selected workbench files into base/comparison in order
useEffect(() => {
const first = base.selectedFiles[0]?.fileId as FileId | undefined;
const second = base.selectedFiles[1]?.fileId as FileId | undefined;
const nextBase: FileId | null = first ?? null;
const nextComp: FileId | null = second ?? null;
// Removed verbose diagnostics
if (params.baseFileId !== nextBase || params.comparisonFileId !== nextComp) {
base.params.setParameters((prev: any) => ({
...prev,
baseFileId: nextBase,
comparisonFileId: nextComp,
}));
}
}, [base.selectedFiles, base.params, params.baseFileId, params.comparisonFileId]);
// Only switch to custom view once per result (prevents update loops)
const lastProcessedAtRef = useRef<number | null>(null);
useEffect(() => {
const { result } = operation;
const { baseFileId, comparisonFileId } = params;
const processedAt = result?.totals.processedAt ?? null;
const hasSelection = Boolean(baseFileId && comparisonFileId);
const matchesSelection = Boolean(
result &&
hasSelection &&
result.base.fileId === baseFileId &&
result.comparison.fileId === comparisonFileId
);
if (matchesSelection && result && processedAt !== null && processedAt !== lastProcessedAtRef.current) {
setCustomWorkbenchViewData(CUSTOM_VIEW_ID, {
result,
baseFileId,
comparisonFileId,
baseLocalFile: null,
comparisonLocalFile: null,
});
// Defer workbench switch to the next frame so the data update is visible to the provider
requestAnimationFrame(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
});
lastProcessedAtRef.current = processedAt;
}
if (!result) {
lastProcessedAtRef.current = null;
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
}
}, [
clearCustomWorkbenchViewData,
navigationActions,
navigationState.selectedTool,
operation.result,
params.baseFileId,
params.comparisonFileId,
setCustomWorkbenchViewData,
params,
]);
const handleOpenWorkbench = useCallback(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
}, [navigationActions]);
const handleExecuteCompare = useCallback(async () => {
const selected: any[] = [];
const baseSel = params.baseFileId ? selectors.getFile(params.baseFileId) : null;
const compSel = params.comparisonFileId ? selectors.getFile(params.comparisonFileId) : null;
if (baseSel) selected.push(baseSel);
if (compSel) selected.push(compSel);
await operation.executeOperation(
{ ...params } as any,
selected as any
);
}, [operation, params, selectors]);
const renderSelectedFile = useCallback(
(role: 'base' | 'comparison') => {
const fileId = role === 'base' ? params.baseFileId : params.comparisonFileId;
const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined;
if (!stub) {
return (
<Card withBorder padding="md" radius="md">
<Text size="sm" c="dimmed">
{t(role === 'base' ? 'compare.base.placeholder' : 'compare.comparison.placeholder', role === 'base' ? 'Select a base PDF' : 'Select a comparison PDF')}
</Text>
</Card>
);
}
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 (
<Card withBorder padding="md" radius="md">
<Group align="flex-start" wrap="nowrap" gap="md">
<Box style={{ width: 64, height: 84, flexShrink: 0 }}>
<DocumentThumbnail file={stub as any} thumbnail={stub?.thumbnailUrl || null} />
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} truncate>
{stub?.name}
</Text>
{meta && (
<Text size="sm" c="dimmed">
{meta}
</Text>
)}
<Button
variant="light"
size="xs"
onClick={() => {
openFilesModal({
maxNumberOfFiles: 1,
customHandler: async (files: File[]) => {
if (!files.length) return;
try {
const added = await fileActions.addFiles(files, { selectFiles: true });
const primary = added[0];
if (!primary) return;
base.params.setParameters((prev: any) => ({
...prev,
baseFileId: role === 'base' ? (primary.fileId as FileId) : prev.baseFileId,
comparisonFileId: role === 'comparison' ? (primary.fileId as FileId) : prev.comparisonFileId,
}));
} catch (e) {
console.error('[compare] replace file failed', e);
}
},
});
}}
disabled={base.operation.isLoading}
>
{t('compare.upload.replaceFile', 'Replace file')}
</Button>
</Stack>
</Group>
</Card>
);
},
[params.baseFileId, params.comparisonFileId, selectors, t]
);
const canExecute = Boolean(
params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId && !base.operation.isLoading && base.endpointEnabled !== false
);
return createToolFlow({
title: {
title: t('compare.title', 'Compare Documents'),
description: t('compare.description', 'Select the base and comparison PDF to highlight differences.'),
},
files: {
selectedFiles: base.selectedFiles,
},
steps: [
{
title: t('compare.base.label', 'Base Document'),
isVisible: true,
content: renderSelectedFile('base'),
},
{
title: t('compare.comparison.label', 'Comparison Document'),
isVisible: true,
content: renderSelectedFile('comparison'),
},
],
executeButton: {
text: t('compare.cta', 'Compare'),
loadingText: t('compare.loading', 'Comparing...'),
onClick: handleExecuteCompare,
disabled: !canExecute,
testId: 'compare-execute',
},
review: {
isVisible: false,
operation: base.operation,
title: t('compare.review.title', 'Comparison Result'),
onUndo: base.operation.undoOperation,
},
});
};
const CompareTool = Compare as ToolComponent;
CompareTool.tool = () => useCompareOperation;
CompareTool.getDefaultParameters = () => ({ ...compareDefaultParameters });
export default CompareTool;

View File

@ -0,0 +1,119 @@
export type CompareDiffTokenType = 'unchanged' | 'removed' | 'added';
export interface CompareDiffToken {
type: CompareDiffTokenType;
text: string;
}
export const REMOVAL_HIGHLIGHT = '#FF3B30';
export const ADDITION_HIGHLIGHT = '#34C759';
export const PARAGRAPH_SENTINEL = '\uE000¶';
export interface TokenBoundingBox {
left: number;
top: number;
width: number;
height: number;
}
export interface CompareTokenMetadata {
page: number;
paragraph: number;
bbox: TokenBoundingBox | null;
}
export interface ComparePageSize {
width: number;
height: number;
}
export interface CompareDocumentInfo {
fileId: string;
fileName: string;
highlightColor: string;
wordCount: number;
pageSizes: ComparePageSize[];
}
export interface CompareParagraph {
page: number;
paragraph: number;
text: string;
}
export interface CompareChangeSide {
text: string;
page: number | null;
paragraph: number | null;
}
export interface CompareChange {
id: string;
base: CompareChangeSide | null;
comparison: CompareChangeSide | null;
}
export interface CompareResultData {
base: CompareDocumentInfo;
comparison: CompareDocumentInfo;
totals: {
added: number;
removed: number;
unchanged: number;
durationMs: number;
processedAt: number;
};
tokens: CompareDiffToken[];
tokenMetadata: {
base: CompareTokenMetadata[];
comparison: CompareTokenMetadata[];
};
sourceTokens: {
base: string[];
comparison: string[];
};
changes: CompareChange[];
warnings: string[];
baseParagraphs: CompareParagraph[];
comparisonParagraphs: CompareParagraph[];
}
export interface CompareWorkerWarnings {
complexMessage?: string;
tooLargeMessage?: string;
emptyTextMessage?: string;
}
export interface CompareWorkerRequest {
type: 'compare';
payload: {
baseTokens: string[];
comparisonTokens: string[];
warnings: CompareWorkerWarnings;
settings?: {
batchSize?: number;
complexThreshold?: number;
maxWordThreshold?: number;
};
};
}
export type CompareWorkerResponse =
| {
type: 'success';
tokens: CompareDiffToken[];
stats: {
baseWordCount: number;
comparisonWordCount: number;
durationMs: number;
};
}
| {
type: 'warning';
message: string;
}
| {
type: 'error';
message: string;
code?: 'EMPTY_TEXT' | 'TOO_LARGE';
};

View File

@ -0,0 +1,63 @@
// Shared text diff and normalization utilities for compare tool
export const PARAGRAPH_SENTINEL = '\uE000¶';
export const shouldConcatWithoutSpace = (word: string) => {
return /^[.,!?;:)\]\}]/.test(word) || word.startsWith("'") || word === "'s";
};
export const appendWord = (existing: string, word: string) => {
if (!existing) return word;
if (shouldConcatWithoutSpace(word)) return `${existing}${word}`;
return `${existing} ${word}`;
};
export const normalizeToken = (s: string) =>
s
.normalize('NFKC')
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '') // soft hyphen + zero width controls
.replace(/[“”]/g, '"')
.replace(/[]/g, "'")
.replace(/[–—]/g, '-')
.replace(/\s+/g, ' ')
.trim();
export const tokenize = (text: string): string[] => text.split(/\s+/).filter(Boolean);
type TokenType = 'unchanged' | 'removed' | 'added';
export interface LocalToken { type: TokenType; text: string }
const buildLcsMatrix = (a: string[], b: string[]) => {
const rows = a.length + 1;
const cols = b.length + 1;
const m: number[][] = new Array(rows);
for (let i = 0; i < rows; i += 1) m[i] = new Array(cols).fill(0);
for (let i = 1; i < rows; i += 1) {
for (let j = 1; j < cols; j += 1) {
m[i][j] = a[i - 1] === b[j - 1] ? m[i - 1][j - 1] + 1 : Math.max(m[i][j - 1], m[i - 1][j]);
}
}
return m;
};
export const diffWords = (a: string[], b: string[]): LocalToken[] => {
const matrix = buildLcsMatrix(a, b);
const tokens: LocalToken[] = [];
let i = a.length;
let j = b.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
tokens.unshift({ type: 'unchanged', text: a[i - 1] });
i -= 1; j -= 1;
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
tokens.unshift({ type: 'added', text: b[j - 1] });
j -= 1;
} else if (i > 0) {
tokens.unshift({ type: 'removed', text: a[i - 1] });
i -= 1;
}
}
return tokens;
};

View File

@ -0,0 +1,178 @@
/// <reference lib="webworker" />
import type {
CompareDiffToken,
CompareWorkerRequest,
CompareWorkerResponse,
} from '../types/compare';
declare const self: DedicatedWorkerGlobalScope;
const DEFAULT_SETTINGS = {
batchSize: 5000,
complexThreshold: 25000,
maxWordThreshold: 60000,
};
const buildMatrix = (words1: string[], words2: string[]) => {
const rows = words1.length + 1;
const cols = words2.length + 1;
const matrix: number[][] = new Array(rows);
for (let i = 0; i < rows; i += 1) {
matrix[i] = new Array(cols).fill(0);
}
for (let i = 1; i <= words1.length; i += 1) {
for (let j = 1; j <= words2.length; j += 1) {
matrix[i][j] =
words1[i - 1] === words2[j - 1]
? matrix[i - 1][j - 1] + 1
: Math.max(matrix[i][j - 1], matrix[i - 1][j]);
}
}
return matrix;
};
const backtrack = (matrix: number[][], words1: string[], words2: string[]): CompareDiffToken[] => {
const tokens: CompareDiffToken[] = [];
let i = words1.length;
let j = words2.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && words1[i - 1] === words2[j - 1]) {
tokens.unshift({ type: 'unchanged', text: words1[i - 1] });
i -= 1;
j -= 1;
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
tokens.unshift({ type: 'added', text: words2[j - 1] });
j -= 1;
} else if (i > 0) {
tokens.unshift({ type: 'removed', text: words1[i - 1] });
i -= 1;
} else {
j -= 1;
}
}
return tokens;
};
const diff = (words1: string[], words2: string[]): CompareDiffToken[] => {
if (words1.length === 0 && words2.length === 0) {
return [];
}
const matrix = buildMatrix(words1, words2);
return backtrack(matrix, words1, words2);
};
const chunkedDiff = (
words1: string[],
words2: string[],
chunkSize: number
): CompareDiffToken[] => {
if (words1.length === 0 && words2.length === 0) {
return [];
}
const tokens: CompareDiffToken[] = [];
let start1 = 0;
let start2 = 0;
// Advance by the actual number of tokens consumed per chunk to maintain alignment
while (start1 < words1.length || start2 < words2.length) {
const slice1 = words1.slice(start1, Math.min(start1 + chunkSize, words1.length));
const slice2 = words2.slice(start2, Math.min(start2 + chunkSize, words2.length));
const chunkTokens = diff(slice1, slice2);
tokens.push(...chunkTokens);
// Count how many tokens from each side were consumed in this chunk
let consumed1 = 0;
let consumed2 = 0;
for (const t of chunkTokens) {
if (t.type === 'unchanged') {
consumed1 += 1; consumed2 += 1;
} else if (t.type === 'removed') {
consumed1 += 1;
} else if (t.type === 'added') {
consumed2 += 1;
}
}
// Fallback to progress by a small step if diff returned nothing (shouldn't happen)
if (consumed1 === 0 && consumed2 === 0) {
consumed1 = Math.min(chunkSize, words1.length - start1);
consumed2 = Math.min(chunkSize, words2.length - start2);
}
start1 += consumed1;
start2 += consumed2;
}
return tokens;
};
self.onmessage = (event: MessageEvent<CompareWorkerRequest>) => {
const { data } = event;
if (!data || data.type !== 'compare') {
return;
}
const { baseTokens, comparisonTokens, warnings, settings } = data.payload;
const {
batchSize = DEFAULT_SETTINGS.batchSize,
complexThreshold = DEFAULT_SETTINGS.complexThreshold,
maxWordThreshold = DEFAULT_SETTINGS.maxWordThreshold,
} = settings ?? {};
if (!baseTokens || !comparisonTokens || baseTokens.length === 0 || comparisonTokens.length === 0) {
const response: CompareWorkerResponse = {
type: 'error',
message: warnings.emptyTextMessage ?? 'One or both texts are empty.',
code: 'EMPTY_TEXT',
};
self.postMessage(response);
return;
}
if (baseTokens.length > maxWordThreshold || comparisonTokens.length > maxWordThreshold) {
const response: CompareWorkerResponse = {
type: 'error',
message: warnings.tooLargeMessage ?? 'Documents are too large to compare.',
code: 'TOO_LARGE',
};
self.postMessage(response);
return;
}
const isComplex = baseTokens.length > complexThreshold || comparisonTokens.length > complexThreshold;
if (isComplex && warnings.complexMessage) {
const warningResponse: CompareWorkerResponse = {
type: 'warning',
message: warnings.complexMessage,
};
self.postMessage(warningResponse);
}
const start = performance.now();
const tokens = isComplex
? chunkedDiff(baseTokens, comparisonTokens, batchSize)
: diff(baseTokens, comparisonTokens);
const durationMs = performance.now() - start;
const response: CompareWorkerResponse = {
type: 'success',
tokens,
stats: {
baseWordCount: baseTokens.length,
comparisonWordCount: comparisonTokens.length,
durationMs,
},
};
self.postMessage(response);
};