Files
Stirling-PDF/frontend/src/tools/Compare.tsx

305 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
import { Box, Group, Stack, Text, Button } from '@mantine/core';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
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 { useFileContext } from '../contexts/file/fileHooks';
import type { FileId } from '../types/file';
import type { StirlingFile } from '../types/fileContext';
import DocumentThumbnail from '../components/shared/filePreview/DocumentThumbnail';
import './compareTool.css';
import type { CompareWorkbenchData } from '../types/compareWorkbench';
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 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 as any,
});
return () => {
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
unregisterCustomWorkbenchView(CUSTOM_VIEW_ID);
};
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
}, []);
// Auto-map from workbench selection: always reflect the first two selected files in order.
// This also handles deselection by promoting the remaining selection to base and clearing comparison.
useEffect(() => {
const selectedIds = base.selectedFiles.map(f => f.fileId as FileId);
// Determine next base: keep current if still selected; otherwise use the first selected id
const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId)
? (params.baseFileId as FileId)
: (selectedIds[0] ?? null);
// Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id
let nextComp: FileId | null = null;
if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) {
nextComp = params.comparisonFileId as FileId;
} else {
nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null;
}
if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) {
base.params.setParameters(prev => ({
...prev,
baseFileId: nextBase,
comparisonFileId: nextComp,
}));
}
}, [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) {
const workbenchData: CompareWorkbenchData = {
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);
});
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: StirlingFile[] = [];
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 },
selected
);
}, [operation, params, selectors]);
// Run compare with explicit ids (used after swap so we don't depend on async state propagation)
const runCompareWithIds = useCallback(async (baseId: FileId | null, compId: FileId | null) => {
const nextParams = { ...params, baseFileId: baseId, comparisonFileId: compId };
const selected: StirlingFile[] = [];
const baseSel = baseId ? selectors.getFile(baseId) : null;
const compSel = compId ? selectors.getFile(compId) : null;
if (baseSel) selected.push(baseSel);
if (compSel) selected.push(compSel);
await operation.executeOperation(nextParams, selected);
}, [operation, params, selectors]);
const handleSwap = useCallback(() => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
if (!baseId || !compId) return;
base.params.setParameters((prev) => ({
...prev,
baseFileId: compId,
comparisonFileId: baseId,
}));
// If 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]);
const renderSelectedFile = useCallback(
(role: 'base' | 'comparison') => {
const fileId = role === 'base' ? params.baseFileId : params.comparisonFileId;
const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined;
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'
)}
</Text>
</Box>
);
}
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">
<Text fw={600} truncate>
{stub?.name}
</Text>
{meta && (
<Text size="sm" c="dimmed">
{meta}
</Text>
)}
</Stack>
</Group>
</Box>
);
},
[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({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: false,
autoExpandNextOnFiles: true, // Next step (selection) opens as soon as files exist
},
steps: [
{
title: t('compare.selection.title', 'Select Base and Comparison'),
isVisible: true,
content: (
<Stack gap="md">
{renderSelectedFile('base')}
{renderSelectedFile('comparison')}
<Group justify="flex-start">
<Button
variant="outline"
onClick={handleSwap}
disabled={!params.baseFileId || !params.comparisonFileId || base.operation.isLoading}
>
{t('compare.swap', 'Swap PDFs')}
</Button>
</Group>
</Stack>
),
},
],
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;