diff --git a/frontend/src/components/shared/LazyLoadContainer.tsx b/frontend/src/components/shared/LazyLoadContainer.tsx new file mode 100644 index 000000000..2adeedb67 --- /dev/null +++ b/frontend/src/components/shared/LazyLoadContainer.tsx @@ -0,0 +1,76 @@ +import React, { useRef, useEffect, useState, ReactNode } from 'react'; + +interface LazyLoadContainerProps { + children: ReactNode; + fallback?: ReactNode; + rootMargin?: string; + threshold?: number; + className?: string; + style?: React.CSSProperties; + onLoad?: () => void; + onUnload?: () => void; +} + +/** + * A reusable lazy loading container that only renders children when they come into view. + * Uses Intersection Observer API for efficient viewport detection. + * + * @param children - Content to render when visible + * @param fallback - Content to show while loading (optional) + * @param rootMargin - Margin around root for intersection detection (default: "50px") + * @param threshold - Intersection ratio threshold (default: 0.1) + * @param className - CSS class name + * @param style - Inline styles + * @param onLoad - Callback when content becomes visible + * @param onUnload - Callback when content becomes hidden + */ +export const LazyLoadContainer: React.FC = ({ + children, + fallback = null, + rootMargin = "50px", + threshold = 0.1, + className, + style, + onLoad, + onUnload, +}) => { + const [hasBeenVisible, setHasBeenVisible] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + const visible = entry.isIntersecting; + + if (visible && !hasBeenVisible) { + setHasBeenVisible(true); + onLoad?.(); + } else if (!visible && hasBeenVisible) { + onUnload?.(); + } + }, + { + rootMargin, + threshold, + } + ); + + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [rootMargin, threshold, hasBeenVisible, onLoad, onUnload]); + + return ( +
+ {hasBeenVisible ? children : fallback} +
+ ); +}; + +export default LazyLoadContainer; diff --git a/frontend/src/components/toast/index.ts b/frontend/src/components/toast/index.ts index d0b1045f2..c169652d6 100644 --- a/frontend/src/components/toast/index.ts +++ b/frontend/src/components/toast/index.ts @@ -1,4 +1,4 @@ -import { ToastOptions } from './types'; +import type { ToastApi, ToastInstance, ToastOptions } from './types'; import { useToast, ToastProvider } from './ToastContext'; import ToastRenderer from './ToastRenderer'; @@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer }; // Global imperative API via module singleton let _api: ReturnType | null = null; +type ToastContextApi = ToastApi & { toasts: ToastInstance[] }; + function createImperativeApi() { - const subscribers: Array<(fn: any) => void> = []; - let api: any = null; + const subscribers: Array<(fn: ToastContextApi) => void> = []; + let api: ToastContextApi | null = null; return { - provide(instance: any) { + provide(instance: ToastContextApi) { api = instance; - subscribers.splice(0).forEach(cb => cb(api)); + subscribers.splice(0).forEach(cb => cb(instance)); + }, + get(): ToastContextApi | null { + return api; + }, + onReady(cb: (readyApi: ToastContextApi) => void) { + if (api) { + cb(api); + } else { + subscribers.push(cb); + } }, - get(): any | null { return api; }, - onReady(cb: (api: any) => void) { - if (api) cb(api); else subscribers.push(cb); - } }; } @@ -58,4 +66,3 @@ export function dismissAllToasts() { _api?.get()?.dismissAll(); } - diff --git a/frontend/src/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/components/tools/compare/CompareDocumentPane.tsx index 848501950..1dc9756ac 100644 --- a/frontend/src/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/components/tools/compare/CompareDocumentPane.tsx @@ -1,14 +1,15 @@ import { Alert, Group, Loader, Stack, Text } from '@mantine/core'; -import { MutableRefObject } from 'react'; +import { RefObject } from 'react'; import type { PagePreview, WordHighlightEntry } from './types'; import type { TokenBoundingBox } from '../../../types/compare'; import CompareNavigationDropdown from './CompareNavigationDropdown'; import { toRgba } from './compareUtils'; +import LazyLoadContainer from '../../shared/LazyLoadContainer'; interface CompareDocumentPaneProps { pane: 'base' | 'comparison'; - scrollRef: MutableRefObject; - peerScrollRef: MutableRefObject; + scrollRef: RefObject; + peerScrollRef: RefObject; handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void; beginPan: (pane: 'base' | 'comparison', event: React.MouseEvent) => void; continuePan: (event: React.MouseEvent) => void; @@ -22,17 +23,14 @@ interface CompareDocumentPaneProps { pan?: { x: number; y: number }; title: string; dropdownPlaceholder?: string; - changes: Array<{ value: string; label: string }>; - onNavigateChange: (id: string) => void; + 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; - highlightColor: string; - highlightOpacity: number; - offsetPixels: number; wordHighlightMap: Map; tokenIndexToGroupId: Map; documentLabel: string; @@ -101,15 +99,16 @@ const CompareDocumentPane = ({ pages, pairedPages, getRowHeightPx, - highlightColor, - highlightOpacity, - offsetPixels, wordHighlightMap, tokenIndexToGroupId, documentLabel, pageLabel, altLabel, }: CompareDocumentPaneProps) => { + // Constants that vary by pane + const HIGHLIGHT_COLOR = pane === 'base' ? '#ff6b6b' : '#51cf66'; // red for base (removals), green for comparison (additions) + const HIGHLIGHT_OPACITY = pane === 'base' ? 0.45 : 0.35; + const OFFSET_PIXELS = pane === 'base' ? 4 : 2; const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto'; const panX = (pan?.x ?? 0); const panY = (pan?.y ?? 0); @@ -165,7 +164,7 @@ const CompareDocumentPane = ({ const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height; const fit = targetHeight / page.height; const rowHeightPx = getRowHeightPx(page.pageNumber); - const highlightOffset = offsetPixels / page.height; + const highlightOffset = OFFSET_PIXELS / page.height; const wordRects = wordHighlightMap.get(page.pageNumber) ?? []; const groupedRects = new Map(); @@ -178,51 +177,91 @@ const CompareDocumentPane = ({ } return ( -
- - {documentLabel} · {pageLabel} {page.pageNumber} - -
+ rootMargin="100px" + threshold={0.1} + fallback={
- {altLabel} - {[...groupedRects.entries()].flatMap(([id, rects]) => - mergeConnectedRects(rects).map((rect, index) => { - const rotation = ((page.rotation ?? 0) % 360 + 360) % 360; - const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset; - return ( - - ); - }) - )} + + {documentLabel} · {pageLabel} {page.pageNumber} + +
+
+
+ +
+
+
+
+ } + > +
+ + {documentLabel} · {pageLabel} {page.pageNumber} + +
+
+ {altLabel} + {[...groupedRects.entries()].flatMap(([id, rects]) => + mergeConnectedRects(rects).map((rect, index) => { + const rotation = ((page.rotation ?? 0) % 360 + 360) % 360; + const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset; + return ( + + ); + }) + )} +
-
+ ); })} diff --git a/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx b/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx index 7f91cd633..ca514d73e 100644 --- a/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx +++ b/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx @@ -2,10 +2,10 @@ import { Combobox, ScrollArea, useCombobox } from '@mantine/core'; import { useMemo, useState } from 'react'; interface NavigationDropdownProps { - changes: Array<{ value: string; label: string }>; + changes: Array<{ value: string; label: string; pageNumber?: number }>; placeholder: string; className?: string; - onNavigate: (value: string) => void; + onNavigate: (value: string, pageNumber?: number) => void; } const CompareNavigationDropdown = ({ @@ -18,11 +18,23 @@ const CompareNavigationDropdown = ({ onDropdownClose: () => combobox.resetSelectedOption(), }); - const sanitize = (s: string) => s - .normalize('NFKC') - .replace(/[\u0000-\u001F\u007F-\u00A0\u2000-\u206F\u2190-\u21FF\u25A0-\u25FF]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); + const sanitize = (s: string) => { + // Normalize and remove control/separator characters without regex ranges + return s + .normalize('NFKC') + .split('') + .map(char => { + const code = char.charCodeAt(0); + // Replace control chars (0-31, 127) and special separators with space + if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) { + return ' '; + } + return char; + }) + .join('') + .replace(/\s+/g, ' ') + .trim(); + }; const isMeaningful = (s: string) => { const t = sanitize(s); @@ -39,7 +51,7 @@ const CompareNavigationDropdown = ({ const normalizedChanges = useMemo(() => { const cleaned = changes - .map((c) => ({ value: c.value, label: sanitize(c.label) })) + .map((c) => ({ value: c.value, label: sanitize(c.label), pageNumber: c.pageNumber })) .filter((c) => isMeaningful(c.label)); const q = sanitize(query).toLowerCase(); if (!q) return cleaned; @@ -51,7 +63,8 @@ const CompareNavigationDropdown = ({ store={combobox} withinPortal={false} onOptionSubmit={(value) => { - onNavigate(value); + const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber; + onNavigate(value, pn); combobox.closeDropdown(); }} // Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly @@ -76,7 +89,7 @@ const CompareNavigationDropdown = ({ value={item.value} key={item.value} onClick={() => { - onNavigate(item.value); + onNavigate(item.value, item.pageNumber); combobox.closeDropdown(); }} > diff --git a/frontend/src/components/tools/compare/CompareUploadSection.tsx b/frontend/src/components/tools/compare/CompareUploadSection.tsx new file mode 100644 index 000000000..3543aabcd --- /dev/null +++ b/frontend/src/components/tools/compare/CompareUploadSection.tsx @@ -0,0 +1,147 @@ +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 '../../../utils/fileUtils'; +import type { StirlingFileStub } from '../../../types/fileContext'; +import LocalIcon from '../../shared/LocalIcon'; +import { useTranslation } from 'react-i18next'; + +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; +} + +interface CompareUploadSectionProps { + heading: string; + subheading: string; + disabled: boolean; + base: UploadColumnProps; + comparison: UploadColumnProps; +} + +const CompareUploadColumn = ({ + role, + file, + stub, + title, + description, + accentClass, + disabled, + onDrop, + onSelectExisting, + onClear, +}: UploadColumnProps) => { + const { t } = useTranslation(); + const openRef = useRef<(() => void) | null>(null); + + const fileLabel = useMemo(() => { + const fileName = stub?.name ?? file?.name ?? null; + const fileSize = stub?.size ?? file?.size ?? null; + if (!fileName) { + return null; + } + return fileSize ? `${fileName} • ${formatFileSize(fileSize)}` : fileName; + }, [file, stub]); + + return ( +
+ void | undefined) | null) => { + openRef.current = instance ?? null; + }) as ForwardedRef<() => void | undefined>} + onDrop={onDrop} + disabled={disabled} + multiple + className="compare-upload-dropzone" + > +
+
+ +
+ + {title} + + + {description} + + +
+ + +
+ + {fileLabel ? ( +
+ + {fileLabel} + + +
+ ) : ( + + {t('compare.upload.instructions', 'Drag & drop here or use the buttons to choose a file.')} + + )} +
+
+
+ ); +}; + +const CompareUploadSection = ({ + heading, + subheading, + disabled, + base, + comparison, +}: CompareUploadSectionProps) => { + return ( + + + + {heading} + + + {subheading} + + +
+ + + + ); +}; + +export default CompareUploadSection; diff --git a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx index e0f09332a..d0bea7214 100644 --- a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx @@ -1,209 +1,167 @@ -import React, { ForwardedRef, JSX, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Button, Group, Loader, Stack, Text, Paper, ActionIcon } from '@mantine/core'; -import { Dropzone } from '@mantine/dropzone'; +import { useCallback } from 'react'; +import { Alert, Stack, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { useMediaQuery } from '@mantine/hooks'; import { - ADDITION_HIGHLIGHT, - CompareDiffToken, CompareResultData, - CompareTokenMetadata, - REMOVAL_HIGHLIGHT, } from '../../../types/compare'; +import type { CompareWorkbenchData } from '../../../types/compareWorkbench'; import type { FileId } from '../../../types/file'; import type { StirlingFileStub, StirlingFile } from '../../../types/fileContext'; import { useFilesModalContext } from '../../../contexts/FilesModalContext'; import { useFileActions, useFileContext } from '../../../contexts/file/fileHooks'; -import { pdfWorkerManager } from '../../../services/pdfWorkerManager'; -import './compareView.css'; -import { tokenize, diffWords, shouldConcatWithoutSpace } from '../../../utils/textDiff'; -import LocalIcon from '../../shared/LocalIcon'; -import { Tooltip } from '../../shared/Tooltip'; import { useRightRailButtons } from '../../../hooks/useRightRailButtons'; -import { alert } from '../../toast'; -import type { ToastLocation } from '../../toast/types'; -import { useMediaQuery } from '@mantine/hooks'; -import { toRgba } from './compareUtils'; -import { PagePreview, WordHighlightEntry } from './types'; import CompareDocumentPane from './CompareDocumentPane'; - -interface CompareWorkbenchData { - result: CompareResultData | null; - baseFileId: FileId | null; - comparisonFileId: FileId | null; - onSelectBase?: (fileId: FileId | null) => void; - onSelectComparison?: (fileId: FileId | null) => void; - isLoading?: boolean; - // Optional direct file references used when files were not added to workbench - baseLocalFile?: StirlingFile | null; - comparisonLocalFile?: StirlingFile | null; -} +import CompareUploadSection from './CompareUploadSection'; +import { useComparePagePreviews } from './hooks/useComparePagePreviews'; +import { useComparePanZoom } from './hooks/useComparePanZoom'; +import { useCompareHighlights } from './hooks/useCompareHighlights'; +import { useCompareChangeNavigation } from './hooks/useCompareChangeNavigation'; +import type { CompareChangeOption } from '../../../types/compareWorkbench'; +import './compareView.css'; +import { useCompareRightRailButtons } from './hooks/useCompareRightRailButtons'; interface CompareWorkbenchViewProps { data: CompareWorkbenchData | null; } - - -const renderInlineParagraph = (baseText: string, comparisonText: string, side: 'base' | 'comparison') => { - const a = tokenize(baseText); - const b = tokenize(comparisonText); - const tokens = diffWords(a, b); - - type SegmentType = 'unchanged' | 'added' | 'removed'; - type Segment = { type: SegmentType; text: string }; - - const segments: Segment[] = []; - - const append = (segmentType: SegmentType, word: string) => { - const last = segments[segments.length - 1]; - if (last && last.type === segmentType) { - // join to existing segment with natural spacing - if (last.text.length > 0 && !shouldConcatWithoutSpace(word)) { - last.text += ' ' + word; - } else { - last.text += word; - } - } else { - segments.push({ type: segmentType, text: word }); - } - }; - - for (const token of tokens) { - if (side === 'base' && token.type === 'added') continue; - if (side === 'comparison' && token.type === 'removed') continue; - append(token.type as SegmentType, token.text); - } - - return ( - - {segments.map((seg, idx) => { - if (seg.type === 'unchanged') { - return {seg.text}; - } - const className = seg.type === 'added' ? 'compare-inline compare-inline--added' : 'compare-inline compare-inline--removed'; - return ( - - {seg.text} - - ); - })} - - ); +const getFileFromSelection = ( + explicit: StirlingFile | null | undefined, + fileId: FileId | null, + selectors: ReturnType['selectors'], +) => { + if (explicit) return explicit; + if (!fileId) return null; + return selectors.getFile(fileId) ?? null; }; - -const renderPdfDocumentToImages = async (file: File): Promise => { - const arrayBuffer = await file.arrayBuffer(); - const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { - disableAutoFetch: true, - disableStream: true, - }); - - try { - const previews: PagePreview[] = []; - // High-DPI rendering while keeping logical display size constant - const DISPLAY_SCALE = 1; // logical CSS size for layout - // Render at very high pixel density so zooming into words remains sharp - const dpr = (typeof window !== 'undefined' ? window.devicePixelRatio : 1); - // Faster initial load; still crisp on common zoom levels. We can re-tune if needed. - const RENDER_SCALE = Math.max(2, Math.min(3, dpr * 2)); - - for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { - const page = await pdf.getPage(pageNumber); - const displayViewport = page.getViewport({ scale: DISPLAY_SCALE }); - const renderViewport = page.getViewport({ scale: RENDER_SCALE }); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - - canvas.width = Math.round(renderViewport.width); - canvas.height = Math.round(renderViewport.height); - - if (!context) { - page.cleanup(); - continue; - } - - await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise; - previews.push({ - pageNumber, - width: Math.round(displayViewport.width), - height: Math.round(displayViewport.height), - rotation: (page.rotate || 0) % 360, - url: canvas.toDataURL(), - }); - - page.cleanup(); - canvas.width = 0; - canvas.height = 0; - } - - return previews; - } finally { - pdfWorkerManager.destroyDocument(pdf); - } +const getStubFromSelection = ( + fileId: FileId | null, + selectors: ReturnType['selectors'], +) => { + if (!fileId) return null; + return selectors.getStirlingFileStub(fileId) ?? null; }; +const getUploadConfig = ( + role: 'base' | 'comparison', + file: File | null, + stub: StirlingFileStub | null, + title: string, + description: string, + accentClass: string, + onDrop: (files: File[]) => void, + onSelectExisting: () => void, + onClear: () => void, + disabled: boolean, +) => ({ + role, + file, + stub, + title, + description, + accentClass, + onDrop, + onSelectExisting, + onClear, + disabled, +}); + +const mapChangesForDropdown = (changes: CompareChangeOption[]) => + changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber })); + const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { const { t } = useTranslation(); + const prefersStacked = useMediaQuery('(max-width: 1024px)') ?? false; const { openFilesModal } = useFilesModalContext(); const { actions: fileActions } = useFileActions(); const { selectors } = useFileContext(); - const prefersStacked = useMediaQuery('(max-width: 1024px)'); - const [layout, setLayout] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side'); - const [basePages, setBasePages] = useState([]); - const [comparisonPages, setComparisonPages] = useState([]); - const [baseLoading, setBaseLoading] = useState(false); - const [comparisonLoading, setComparisonLoading] = useState(false); - - const baseScrollRef = useRef(null); - const comparisonScrollRef = useRef(null); - const isSyncingRef = useRef(false); - const [isScrollLinked, setIsScrollLinked] = useState(true); - // Maintain normalized deltas so panes keep their relative positions when re-linked - const scrollLinkDeltaRef = useRef<{ vertical: number; horizontal: number }>({ vertical: 0, horizontal: 0 }); - const [isPanMode, setIsPanMode] = useState(false); - const panDragRef = useRef<{ active: boolean; source: 'base' | 'comparison' | null; startX: number; startY: number; startPanX: number; startPanY: number; targetStartPanX: number; targetStartPanY: number }>( - { active: false, source: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0, targetStartPanX: 0, targetStartPanY: 0 } - ); - const lastActivePaneRef = useRef<'base' | 'comparison'>('base'); - const [baseZoom, setBaseZoom] = useState(1); - const [comparisonZoom, setComparisonZoom] = useState(1); - const [basePan, setBasePan] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); - const [comparisonPan, setComparisonPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); - const ZOOM_MIN = 0.5; - const ZOOM_MAX = 100000; - const ZOOM_STEP = 0.1; - const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 }); - const pinchRef = useRef<{ active: boolean; pane: 'base' | 'comparison' | null; startDistance: number; startZoom: number }>( - { active: false, pane: null, startDistance: 0, startZoom: 1 } - ); - - // Compute maximum canvas size (unzoomed) across pages for pan bounds - - - const result = data?.result ?? null; + const result: CompareResultData | null = data?.result ?? null; const baseFileId = data?.baseFileId ?? null; const comparisonFileId = data?.comparisonFileId ?? null; const onSelectBase = data?.onSelectBase; const onSelectComparison = data?.onSelectComparison; const isOperationLoading = data?.isLoading ?? false; - const baseOpenRef = useRef<(() => void) | null>(null); - const comparisonOpenRef = useRef<(() => void) | null>(null); + const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors); + const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors); + const baseStub = getStubFromSelection(baseFileId, selectors); + const comparisonStub = getStubFromSelection(comparisonFileId, selectors); - const baseFile = data?.baseLocalFile ?? (baseFileId ? selectors.getFile(baseFileId) ?? null : null); - const comparisonFile = data?.comparisonLocalFile ?? (comparisonFileId ? selectors.getFile(comparisonFileId) ?? null : null); - const baseStub = baseFileId ? selectors.getStirlingFileStub(baseFileId) ?? null : null; - const comparisonStub = comparisonFileId ? selectors.getStirlingFileStub(comparisonFileId) ?? null : null; + const processedAt = result?.totals.processedAt ?? null; - const handleFilesAdded = useCallback( - async (files: File[], role: 'base' | 'comparison') => { + const { pages: basePages, loading: baseLoading } = useComparePagePreviews({ + file: baseFile, + enabled: Boolean(result && baseFile), + cacheKey: processedAt, + }); + + const { pages: comparisonPages, loading: comparisonLoading } = useComparePagePreviews({ + file: comparisonFile, + enabled: Boolean(result && comparisonFile), + cacheKey: processedAt, + }); + + const { + layout, + toggleLayout, + baseScrollRef, + comparisonScrollRef, + handleScrollSync, + beginPan, + continuePan, + endPan, + handleWheelZoom, + onTouchStart, + onTouchMove, + onTouchEnd, + isPanMode, + setIsPanMode, + baseZoom, + setBaseZoom, + comparisonZoom, + setComparisonZoom, + basePan, + comparisonPan, + centerPanForZoom, + clampPanForZoom, + clearScrollLinkDelta, + captureScrollLinkDelta, + setIsScrollLinked, + isScrollLinked, + zoomLimits, + } = useComparePanZoom({ + basePages, + comparisonPages, + prefersStacked, + }); + + const { + baseWordChanges, + comparisonWordChanges, + tokenIndexToGroupId, + wordHighlightMaps, + getRowHeightPx, + } = useCompareHighlights(result, basePages, comparisonPages); + + const handleChangeNavigation = useCompareChangeNavigation( + baseScrollRef, + comparisonScrollRef + ); + + 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 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: true }); + const added = await fileActions.addFiles(files, { selectFiles: false }); const primary = added[0]; if (!primary) { return; @@ -216,152 +174,9 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { } catch (error) { console.error('[compare] failed to add files from workbench dropzone', error); } - }, - [fileActions, isOperationLoading, onSelectBase, onSelectComparison] - ); - // Toggle layout via Right Rail - const toggleLayout = useCallback(() => { - setLayout(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side')); - }, []); + }, [fileActions, isOperationLoading, onSelectBase, onSelectComparison]); - // Default to stacked on mobile and when screen shrinks; return to side-by-side when expanded - useEffect(() => { - setLayout(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev)); - }, [prefersStacked]); - - const rightRailButtons = useMemo(() => [ - { - id: 'compare-toggle-layout', - icon: , - tooltip: layout === 'side-by-side' ? t('compare.actions.stackVertically', 'Stack vertically') : t('compare.actions.placeSideBySide', 'Place side by side'), - ariaLabel: layout === 'side-by-side' ? t('compare.actions.stackVertically', 'Stack vertically') : t('compare.actions.placeSideBySide', 'Place side by side'), - section: 'top' as const, - order: 10, - onClick: toggleLayout, - }, - { - id: 'compare-pan-mode', - section: 'top' as const, - order: 12, - render: ({ disabled }: { disabled: boolean }) => ( - - setIsPanMode(prev => !prev)} - disabled={disabled} - aria-label={t('rightRail.panMode', 'Pan Mode')} - style={isPanMode ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined} - > - - - - ), - }, - { - id: 'compare-zoom-out', - icon: , - tooltip: t('compare.actions.zoomOut', 'Zoom out'), - ariaLabel: t('compare.actions.zoomOut', 'Zoom out'), - section: 'top' as const, - order: 13, - onClick: () => { - const nextBase = Math.max(ZOOM_MIN, +(baseZoom - ZOOM_STEP).toFixed(2)); - const nextComp = Math.max(ZOOM_MIN, +(comparisonZoom - ZOOM_STEP).toFixed(2)); - setBaseZoom(nextBase); - setComparisonZoom(nextComp); - centerPanForZoom('base', nextBase); - centerPanForZoom('comparison', nextComp); - }, - }, - { - id: 'compare-zoom-in', - icon: , - tooltip: t('compare.actions.zoomIn', 'Zoom in'), - ariaLabel: t('compare.actions.zoomIn', 'Zoom in'), - section: 'top' as const, - order: 14, - onClick: () => { - const nextBase = Math.min(ZOOM_MAX, +(baseZoom + ZOOM_STEP).toFixed(2)); - const nextComp = Math.min(ZOOM_MAX, +(comparisonZoom + ZOOM_STEP).toFixed(2)); - setBaseZoom(nextBase); - setComparisonZoom(nextComp); - clampPanForZoom('base', nextBase); - clampPanForZoom('comparison', nextComp); - }, - }, - { - id: 'compare-reset-view', - icon: , - tooltip: t('compare.actions.resetView', 'Reset zoom and pan'), - ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'), - section: 'top' as const, - order: 14.5, - onClick: () => { - // Reset zoom on both panes; keep current scroll positions unchanged - setBaseZoom(1); - setComparisonZoom(1); - // Center content for default zoom - centerPanForZoom('base', 1); - centerPanForZoom('comparison', 1); - // Clear any stored link delta; next link will recompute from current scrolls - scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 }; - }, - }, - { - id: 'compare-toggle-scroll-link', - icon: , - tooltip: isScrollLinked ? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan') : t('compare.actions.linkScrollPan', 'Link scroll and pan'), - ariaLabel: isScrollLinked ? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan') : t('compare.actions.linkScrollPan', 'Link scroll and pan'), - section: 'top' as const, - order: 15, - onClick: () => { - // Toggling from unlinked -> linked: compute current normalized delta so we preserve ratio - const next = !isScrollLinked; - if (next) { - const baseEl = baseScrollRef.current; - const compEl = comparisonScrollRef.current; - if (baseEl && compEl) { - const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight); - const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight); - const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth); - const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth); - - const baseV = baseEl.scrollTop / baseVMax; - const compV = compEl.scrollTop / compVMax; - const baseH = baseEl.scrollLeft / baseHMax; - const compH = compEl.scrollLeft / compHMax; - - scrollLinkDeltaRef.current = { - vertical: compV - baseV, - horizontal: compH - baseH, - }; - } - } - if (!next) { - alert({ - alertType: 'neutral', - title: t('compare.toasts.unlinkedTitle', 'Independent scroll & pan enabled'), - body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes; panning only moves the active pane.'), - durationMs: 5000, - location: 'bottom-center' as ToastLocation, - expandable: false, - }); - } - setIsScrollLinked(next); - }, - }, - ], [layout, t, toggleLayout, isScrollLinked, isPanMode, baseZoom, comparisonZoom]); - - useRightRailButtons(rightRailButtons); - - // Removed pane measurement; rely on CSS width:100% for responsive fit - - - const handleSelectFromLibrary = useCallback( - (role: 'base' | 'comparison') => { + const handleSelectFromLibrary = useCallback((role: 'base' | 'comparison') => { if (isOperationLoading) { return; } @@ -370,12 +185,9 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { await handleFilesAdded(files, role); }, }); - }, - [handleFilesAdded, isOperationLoading, openFilesModal] - ); + }, [handleFilesAdded, isOperationLoading, openFilesModal]); - const handleClearSelection = useCallback( - (role: 'base' | 'comparison') => { + const handleClearSelection = useCallback((role: 'base' | 'comparison') => { if (isOperationLoading) { return; } @@ -384,917 +196,79 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { } else { onSelectComparison?.(null); } - }, - [isOperationLoading, onSelectBase, onSelectComparison] - ); + }, [isOperationLoading, onSelectBase, onSelectComparison]); - const formatFileSize = (size?: number) => { - if (!size || Number.isNaN(size)) { - return ''; - } - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let value = size; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; - }; - - const renderUploadColumn = useCallback( - ( - role: 'base' | 'comparison', - { - openRef, - file, - stub, - title, - description, - accentClass, - }: { - openRef: MutableRefObject<(() => void) | null>; - file: File | null; - stub: StirlingFileStub | null; - title: string; - description: string; - accentClass: string; - } - ) => { - const fileName = stub?.name ?? file?.name ?? null; - const fileSize = stub?.size ?? file?.size ?? null; - const fileLabel = fileName ? `${fileName}${fileSize ? ` • ${formatFileSize(fileSize)}` : ''}` : null; - - const handleDrop = (dropped: File[]) => handleFilesAdded(dropped, role); - const handleUploadClick = () => openRef.current?.(); - const handleLibraryClick = () => handleSelectFromLibrary(role); - const handleClearClick = () => handleClearSelection(role); - - return ( -
- void | undefined) | null) => { - openRef.current = instance ?? null; - }) as ForwardedRef<() => void | undefined>} - onDrop={handleDrop} - disabled={isOperationLoading} - multiple - className="compare-upload-dropzone" - > -
-
- -
- - {title} - - - {description} - - -
- - -
- - {fileLabel ? ( -
- - {fileLabel} - - -
- ) : ( - - {t('compare.upload.instructions', 'Drag & drop here or use the buttons to choose a file.')} - - )} -
-
-
- ); - }, - [handleClearSelection, handleFilesAdded, handleSelectFromLibrary, isOperationLoading, t] - ); - - const renderUploadLayout = () => ( - - - - {t('compare.upload.title', 'Set up your comparison')} - - - {t( + const uploadSection = ( + - -
- {renderUploadColumn('base', { - openRef: baseOpenRef, - file: baseFile, - stub: baseStub, - title: t('compare.upload.baseTitle', 'Base document'), - description: t('compare.upload.baseDescription', 'This version acts as the reference for differences.'), - accentClass: 'compare-upload-icon--base', - })} - - + 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, + )} + /> ); - useEffect(() => { - let cancelled = false; - - const render = async () => { - if (!baseFile || !result) { - setBasePages([]); - return; - } - setBaseLoading(true); - try { - const previews = await renderPdfDocumentToImages(baseFile); - if (!cancelled) { - setBasePages(previews); - } - } catch (error) { - console.error('[compare] failed to render base document preview', error); - if (!cancelled) { - setBasePages([]); - } - } finally { - if (!cancelled) { - setBaseLoading(false); - } - } - }; - - render(); - - return () => { - cancelled = true; - }; - }, [baseFile, result?.totals.processedAt]); - - useEffect(() => { - let cancelled = false; - - const render = async () => { - if (!comparisonFile || !result) { - setComparisonPages([]); - return; - } - setComparisonLoading(true); - try { - const previews = await renderPdfDocumentToImages(comparisonFile); - if (!cancelled) { - setComparisonPages(previews); - } - } catch (error) { - console.error('[compare] failed to render comparison document preview', error); - if (!cancelled) { - setComparisonPages([]); - } - } finally { - if (!cancelled) { - setComparisonLoading(false); - } - } - }; - - render(); - - return () => { - cancelled = true; - }; - }, [comparisonFile, result?.totals.processedAt]); - - const totals = result?.totals ?? { - added: 0, - removed: 0, - unchanged: 0, - durationMs: 0, - processedAt: Date.now(), - }; - - 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 pageLabel = t('compare.summary.pageLabel', 'Page'); - - - - const handleScrollSync = (source: HTMLDivElement | null, target: HTMLDivElement | null) => { - // Do not sync while panning; panning should only affect the active pane - if (panDragRef.current.active) return; - if (!source || !target || isSyncingRef.current || !isScrollLinked) { - return; - } - - // Track last interacted pane for targeted zoom when unlinked - lastActivePaneRef.current = source === baseScrollRef.current ? 'base' : 'comparison'; - - const sourceIsBase = source === baseScrollRef.current; - const deltaV = scrollLinkDeltaRef.current.vertical; - - // Normalize positions (guard against zero scroll ranges) - const sVMax = Math.max(1, source.scrollHeight - source.clientHeight); - const sHMax = Math.max(1, source.scrollWidth - source.clientWidth); - const tVMax = Math.max(1, target.scrollHeight - target.clientHeight); - // If target cannot scroll vertically, skip syncing to avoid jumps - if (tVMax <= 1) { - return; - } - - const sV = source.scrollTop / sVMax; - const sH = source.scrollLeft / sHMax; - - // If base is source, comp = base + delta; if comp is source, base = comp - delta - const desiredTV = sourceIsBase ? sV + deltaV : sV - deltaV; - // Only sync vertical scrolling to avoid layout-induced jumps with differing orientations - - const clampedTV = Math.max(0, Math.min(1, desiredTV)); - // Horizontal sync disabled intentionally - - isSyncingRef.current = true; - target.scrollTop = clampedTV * tVMax; - requestAnimationFrame(() => { - isSyncingRef.current = false; - }); - }; - - const getMaxCanvasSize = useCallback((pane: 'base' | 'comparison') => { - const pages = pane === 'base' ? basePages : comparisonPages; - const peers = pane === 'base' ? comparisonPages : basePages; - let maxW = 0; - let maxH = 0; - for (const page of pages) { - const peer = peers.find(p => p.pageNumber === page.pageNumber); - const targetHeight = peer ? Math.max(page.height, peer.height) : page.height; - const fit = targetHeight / page.height; - const width = Math.round(page.width * fit); - const height = Math.round(targetHeight); - if (width > maxW) maxW = width; - if (height > maxH) maxH = height; - } - return { maxW, maxH }; - }, [basePages, comparisonPages]); - - const getPanBounds = useCallback((pane: 'base' | 'comparison', zoomOverride?: number) => { - // Prefer actual canvas size from DOM for the current pane; fallback to precomputed max - const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current; - const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null; - let canvasW: number | null = null; - let canvasH: number | null = null; - if (canvasEl) { - const rect = canvasEl.getBoundingClientRect(); - canvasW = Math.max(0, Math.round(rect.width)); - canvasH = Math.max(0, Math.round(rect.height)); - } - - const fallback = getMaxCanvasSize(pane); - const W = canvasW ?? fallback.maxW; - const H = canvasH ?? fallback.maxH; - const zoom = zoomOverride !== undefined ? zoomOverride : (pane === 'base' ? baseZoom : comparisonZoom); - // Content grows by (zoom - 1) relative to viewport (transform-origin: top-left) - // So the maximum pan equals contentWidth - viewportWidth = W * (zoom - 1) - const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1)); - const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1)); - return { maxX: extraX, maxY: extraY }; - }, [getMaxCanvasSize, baseZoom, comparisonZoom]); - - const getPaneRotation = useCallback((pane: 'base' | 'comparison') => { - const pages = pane === 'base' ? basePages : comparisonPages; - // Use first page rotation (assume uniform in compare context) - const r = pages[0]?.rotation ?? 0; - const norm = ((r % 360) + 360) % 360; - return norm as 0 | 90 | 180 | 270 | number; - }, [basePages, comparisonPages]); - - // Map pan from source pane to equivalent logical location in target pane accounting for rotation - const mapPanBetweenOrientations = useCallback(( - source: 'base' | 'comparison', - target: 'base' | 'comparison', - sourcePan: { x: number; y: number } - ) => { - const sRot = getPaneRotation(source); - const tRot = getPaneRotation(target); - const sBounds = getPanBounds(source); - const tBounds = getPanBounds(target); - - // Use symmetric normalized coordinates with origin at content center to improve perceptual alignment - const sx = sBounds.maxX === 0 ? 0 : (sourcePan.x / sBounds.maxX) * 2 - 1; // [-1, 1] - const sy = sBounds.maxY === 0 ? 0 : (sourcePan.y / sBounds.maxY) * 2 - 1; // [-1, 1] - - // Convert to logical normalized coords with origin at top-left regardless of rotation - // For a zoomed canvas, pan (x,y) means how far we've moved from origin within extra space - // Normalized mapping across rotations: - // rot 0: (nx, ny) - // rot 90: (ny, 1 - nx) - // rot 180: (1 - nx, 1 - ny) - // rot 270: (1 - ny, nx) - const apply = (nx: number, ny: number, rot: number) => { - const r = ((rot % 360) + 360) % 360; - if (r === 0) return { nx, ny }; - if (r === 90) return { nx: ny, ny: -nx }; - if (r === 180) return { nx: -nx, ny: -ny }; - if (r === 270) return { nx: -ny, ny: nx }; - // Fallback for non-right-angle rotations (shouldn't occur here) - return { nx, ny }; - }; - - const logical = apply(sx, sy, sRot); - const targetCentered = apply(logical.nx, logical.ny, (360 - tRot)); - - // Map back from [-1,1] centered to [0,1] top-left origin before scaling - const targetNormX = (targetCentered.nx + 1) / 2; - const targetNormY = (targetCentered.ny + 1) / 2; - - const tx = Math.max(0, Math.min(tBounds.maxX, targetNormX * tBounds.maxX)); - const ty = Math.max(0, Math.min(tBounds.maxY, targetNormY * tBounds.maxY)); - return { x: tx, y: ty }; - }, [getPaneRotation, getPanBounds]); - - const reconcileLinkedPan = useCallback(( - source: 'base' | 'comparison', - desiredActive: { x: number; y: number } - ) => { - const other: 'base' | 'comparison' = source === 'base' ? 'comparison' : 'base'; - const desiredOther = mapPanBetweenOrientations(source, other, desiredActive); - const otherBounds = getPanBounds(other); - const clampedOther = { - x: Math.max(0, Math.min(otherBounds.maxX, desiredOther.x)), - y: Math.max(0, Math.min(otherBounds.maxY, desiredOther.y)), - }; - // Do NOT constrain the active pane due to peer clamp; keep desiredActive (already clamped to source bounds earlier) - return { active: desiredActive, other: clampedOther }; - }, [getPanBounds, mapPanBetweenOrientations]); - - const centerPanForZoom = useCallback((pane: 'base' | 'comparison', zoomValue: number) => { - const bounds = getPanBounds(pane, zoomValue); - const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) }; - if (pane === 'base') setBasePan(center); else setComparisonPan(center); - }, [getPanBounds]); - - const clampPanForZoom = useCallback((pane: 'base' | 'comparison', zoomValue: number) => { - const bounds = getPanBounds(pane, zoomValue); - const current = pane === 'base' ? basePan : comparisonPan; - const clamped = { - x: Math.max(0, Math.min(bounds.maxX, current.x)), - y: Math.max(0, Math.min(bounds.maxY, current.y)), - }; - if (pane === 'base') setBasePan(clamped); else setComparisonPan(clamped); - }, [getPanBounds, basePan, comparisonPan]); - - const beginPan = (pane: 'base' | 'comparison', e: React.MouseEvent) => { - if (!isPanMode) return; - const zoom = pane === 'base' ? baseZoom : comparisonZoom; - if (zoom <= 1) return; - const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current; - const other = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current; - if (!container) return; - // Only start inner-content panning when the drag starts over the image content - const targetEl = e.target as HTMLElement | null; - const isOnImage = !!targetEl?.closest('.compare-diff-page__inner'); - if (!isOnImage) return; // allow normal scrolling outside the image - e.preventDefault(); - panDragRef.current = { - active: true, - source: pane, - startX: e.clientX, - startY: e.clientY, - startPanX: pane === 'base' ? basePan.x : comparisonPan.x, - startPanY: pane === 'base' ? basePan.y : comparisonPan.y, - targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x, - targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y, - }; - lastActivePaneRef.current = pane; - (container as HTMLDivElement).style.cursor = 'grabbing'; - }; - - const continuePan = (e: React.MouseEvent) => { - if (!isPanMode) return; - const drag = panDragRef.current; - if (!drag.active || !drag.source) return; - - const dx = e.clientX - drag.startX; - const dy = e.clientY - drag.startY; - - const isBase = drag.source === 'base'; - const bounds = getPanBounds(drag.source); - const desired = { - x: Math.max(0, Math.min(bounds.maxX, drag.startPanX - dx)), - y: Math.max(0, Math.min(bounds.maxY, drag.startPanY - dy)), - }; - if (isScrollLinked) { - // Active-dominant: always set the active pane to desired; map and clamp peer only - if (isBase) setBasePan(desired); else setComparisonPan(desired); - const otherPane: 'base' | 'comparison' = isBase ? 'comparison' : 'base'; - const mappedPeer = mapPanBetweenOrientations(drag.source, otherPane, desired); - const peerBounds = getPanBounds(otherPane); - const clampedPeer = { - x: Math.max(0, Math.min(peerBounds.maxX, mappedPeer.x)), - y: Math.max(0, Math.min(peerBounds.maxY, mappedPeer.y)), - }; - if (isBase) setComparisonPan(clampedPeer); else setBasePan(clampedPeer); - } else { - if (isBase) setBasePan(desired); else setComparisonPan(desired); - } - }; - - const endPan = () => { - const drag = panDragRef.current; - if (!drag.active) return; - const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current; - if (sourceEl) { - (sourceEl as HTMLDivElement).style.cursor = isPanMode ? (drag.source === 'base' ? (baseZoom > 1 ? 'grab' : 'auto') : (comparisonZoom > 1 ? 'grab' : 'auto')) : ''; - } - panDragRef.current.active = false; - panDragRef.current.source = null; - }; - - // Wheel pinch-to-zoom (trackpad): ctrlKey is true during pinch on most browsers - const handleWheelZoom = (pane: 'base' | 'comparison', e: React.WheelEvent) => { - if (!e.ctrlKey) return; // Only treat as pinch-zoom if ctrlKey (prevents accidental zoom) - e.preventDefault(); - const key = pane === 'base' ? 'base' : 'comparison'; - const accum = wheelZoomAccumRef.current; - const threshold = 180; // Larger threshold => less sensitive - accum[key] += e.deltaY; - const steps = Math.trunc(Math.abs(accum[key]) / threshold); - if (steps <= 0) return; - const dir = accum[key] > 0 ? -1 : 1; // deltaY>0 => zoom out - accum[key] = accum[key] % threshold; - const applySteps = (z: number) => { - let next = z; - for (let i = 0; i < steps; i += 1) { - next = dir > 0 ? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2)) : Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2)); - } - return next; - }; - if (pane === 'base') { - const prev = baseZoom; - const next = applySteps(prev); - setBaseZoom(next); - // Recenter when zooming out; clamp when zooming in - if (next < prev) centerPanForZoom('base', next); else clampPanForZoom('base', next); - } else { - const prev = comparisonZoom; - const next = applySteps(prev); - setComparisonZoom(next); - if (next < prev) centerPanForZoom('comparison', next); else clampPanForZoom('comparison', next); - } - }; - - // Touch pinch-to-zoom on mobile - const onTouchStart = (pane: 'base' | 'comparison', e: React.TouchEvent) => { - if (e.touches.length === 2) { - const [t1, t2] = [e.touches[0], e.touches[1]]; - const dx = t1.clientX - t2.clientX; - const dy = t1.clientY - t2.clientY; - pinchRef.current = { - active: true, - pane, - startDistance: Math.hypot(dx, dy), - startZoom: pane === 'base' ? baseZoom : comparisonZoom, - }; - e.preventDefault(); - } else if (e.touches.length === 1) { - if (!isPanMode) return; - const zoom = pane === 'base' ? baseZoom : comparisonZoom; - if (zoom <= 1) return; - const targetEl = e.target as HTMLElement | null; - const isOnImage = !!targetEl?.closest('.compare-diff-page__inner'); - if (!isOnImage) return; - const touch = e.touches[0]; - panDragRef.current = { - active: true, - source: pane, - startX: touch.clientX, - startY: touch.clientY, - startPanX: pane === 'base' ? basePan.x : comparisonPan.x, - startPanY: pane === 'base' ? basePan.y : comparisonPan.y, - targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x, - targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y, - }; - e.preventDefault(); - } - }; - - const onTouchMove = (e: React.TouchEvent) => { - if (pinchRef.current.active && e.touches.length === 2) { - const [t1, t2] = [e.touches[0], e.touches[1]]; - const dx = t1.clientX - t2.clientX; - const dy = t1.clientY - t2.clientY; - const distance = Math.hypot(dx, dy); - const scale = distance / Math.max(1, pinchRef.current.startDistance); - const dampened = 1 + (scale - 1) * 0.6; - const pane = pinchRef.current.pane!; - const startZoom = pinchRef.current.startZoom; - const prevZoom = pane === 'base' ? baseZoom : comparisonZoom; - const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2))); - if (pane === 'base') { - setBaseZoom(nextZoom); - if (nextZoom < prevZoom) centerPanForZoom('base', nextZoom); // zoom out => center - // zoom in: preserve current focal area by not jumping; just clamp within new bounds - if (nextZoom > prevZoom) clampPanForZoom('base', nextZoom); - } else { - setComparisonZoom(nextZoom); - if (nextZoom < prevZoom) centerPanForZoom('comparison', nextZoom); - if (nextZoom > prevZoom) clampPanForZoom('comparison', nextZoom); - } - e.preventDefault(); - return; - } - // One-finger pan - if (panDragRef.current.active && e.touches.length === 1) { - const touch = e.touches[0]; - const dx = touch.clientX - panDragRef.current.startX; - const dy = touch.clientY - panDragRef.current.startY; - const isBase = panDragRef.current.source === 'base'; - const bounds = getPanBounds(panDragRef.current.source!); - const desired = { - x: Math.max(0, Math.min(bounds.maxX, panDragRef.current.startPanX - dx)), - y: Math.max(0, Math.min(bounds.maxY, panDragRef.current.startPanY - dy)), - }; - if (isScrollLinked) { - if (isBase) setBasePan(desired); else setComparisonPan(desired); - const otherPane: 'base' | 'comparison' = isBase ? 'comparison' : 'base'; - const mappedPeer = mapPanBetweenOrientations(isBase ? 'base' : 'comparison', otherPane, desired); - const peerBounds = getPanBounds(otherPane); - const clampedPeer = { - x: Math.max(0, Math.min(peerBounds.maxX, mappedPeer.x)), - y: Math.max(0, Math.min(peerBounds.maxY, mappedPeer.y)), - }; - if (isBase) setComparisonPan(clampedPeer); else setBasePan(clampedPeer); - } else { - if (isBase) setBasePan(desired); else setComparisonPan(desired); - } - e.preventDefault(); - } - }; - - const onTouchEnd = () => { - pinchRef.current.active = false; - pinchRef.current.pane = null; - panDragRef.current.active = false; - }; - - // Keyboard handler: when unlinked, ArrowUp/Down scroll both panes in the same direction - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (isScrollLinked) return; - const target = e.target as HTMLElement | null; - const tag = (target?.tagName || '').toLowerCase(); - const isEditable = target && (tag === 'input' || tag === 'textarea' || (target.getAttribute('contenteditable') === 'true')); - if (isEditable) return; - - const baseEl = baseScrollRef.current; - const compEl = comparisonScrollRef.current; - if (!baseEl || !compEl) return; - - const STEP = 80; // pixels per key press - - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - const delta = e.key === 'ArrowDown' ? STEP : -STEP; - isSyncingRef.current = true; - baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight)); - compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight)); - requestAnimationFrame(() => { - isSyncingRef.current = false; - }); - } - }; - - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [isScrollLinked]); - - // Group tokens into sentences for better UX - const groupTokensBySentence = useCallback((tokens: CompareDiffToken[], metadata: CompareTokenMetadata[]) => { - const sentences: Array<{ - tokens: CompareDiffToken[]; - metadata: CompareTokenMetadata[]; - pageNumber: number; - startIndex: number; - }> = []; - - let currentSentence: CompareDiffToken[] = []; - let currentMetadata: CompareTokenMetadata[] = []; - let currentPage = -1; - let startIndex = 0; - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - const meta = metadata[i]; - - if (token.type === 'unchanged') continue; - - // Check if this starts a new sentence (ends with punctuation or is a new page) - const isNewSentence = currentSentence.length > 0 && ( - token.text.match(/[.!?]\s*$/) || // Ends with sentence-ending punctuation - token.text.match(/^\s*[A-Z][a-z]/) || // Starts with capital letter followed by lowercase (likely new sentence) - meta?.page !== currentPage - ); - - if (isNewSentence) { - if (currentSentence.length > 0) { - sentences.push({ - tokens: [...currentSentence], - metadata: [...currentMetadata], - pageNumber: currentPage, - startIndex, - }); - } - currentSentence = [token]; - currentMetadata = [meta]; - currentPage = meta?.page || currentPage; - startIndex = i; - } else { - currentSentence.push(token); - if (meta) currentMetadata.push(meta); - if (meta?.page && currentPage === -1) currentPage = meta.page; - } - } - - // Add remaining sentence - if (currentSentence.length > 0) { - sentences.push({ - tokens: currentSentence, - metadata: currentMetadata, - pageNumber: currentPage, - startIndex, - }); - } - - return sentences; - }, []); - - // Create a mapping from token index to sentence group - const createTokenToSentenceMap = useCallback((tokens: CompareDiffToken[], isBase: boolean) => { - const sentenceGroups = groupTokensBySentence( - tokens, - isBase ? result!.tokenMetadata.base : result!.tokenMetadata.comparison - ); - - const tokenToSentenceMap = new Map(); - const sentenceToTokensMap = new Map(); - - sentenceGroups.forEach((group, groupIndex) => { - const relevantTokens = group.tokens.filter(t => isBase ? t.type === 'removed' : t.type === 'added'); - sentenceToTokensMap.set(groupIndex, relevantTokens.map((_, tokenIndex) => - tokens.indexOf(relevantTokens[tokenIndex]) - )); - - relevantTokens.forEach((token, tokenIndex) => { - const originalIndex = tokens.indexOf(token); - tokenToSentenceMap.set(originalIndex, groupIndex); - }); - }); - - return { sentenceGroups, tokenToSentenceMap, sentenceToTokensMap }; - }, [groupTokensBySentence, result]); - - // Build per-word change items with stable IDs that map 1:1 to highlight elements - const baseTokenIndexToGroupId = useMemo(() => new Map(), []); - const baseWordChanges = useMemo(() => { - baseTokenIndexToGroupId.clear(); - if (!result) return [] as Array<{ value: string; label: string; pageNumber: number }>; - const items: Array<{ value: string; label: string; pageNumber: number }> = []; - let baseIndex = 0; - for (let i = 0; i < result.tokens.length; i += 1) { - const token = result.tokens[i]; - if (token.type === 'removed') { - const startIndex = baseIndex; - const parts: string[] = []; - const runIndices: number[] = []; - let pageNumber = result.tokenMetadata.base[baseIndex]?.page ?? 1; - // accumulate contiguous removed tokens (record indices first) - while (i < result.tokens.length && result.tokens[i].type === 'removed') { - parts.push(result.tokens[i].text); - runIndices.push(baseIndex); - baseIndex += 1; - i += 1; - } - i -= 1; // step back because for-loop will ++ - const endIndex = baseIndex - 1; - const groupId = `base-group-${startIndex}-${endIndex}`; - runIndices.forEach(idx => baseTokenIndexToGroupId.set(idx, groupId)); - const label = parts.join(' ').trim(); - items.push({ value: groupId, label: label || '(…)', pageNumber }); - continue; - } - if (token.type !== 'added') { - baseIndex += 1; - } - } - return items; - }, [result, baseTokenIndexToGroupId]); - - const comparisonTokenIndexToGroupId = useMemo(() => new Map(), []); - const comparisonWordChanges = useMemo(() => { - comparisonTokenIndexToGroupId.clear(); - if (!result) return [] as Array<{ value: string; label: string; pageNumber: number }>; - const items: Array<{ value: string; label: string; pageNumber: number }> = []; - let comparisonIndex = 0; - for (let i = 0; i < result.tokens.length; i += 1) { - const token = result.tokens[i]; - if (token.type === 'added') { - const startIndex = comparisonIndex; - const parts: string[] = []; - const runIndices: number[] = []; - let pageNumber = result.tokenMetadata.comparison[comparisonIndex]?.page ?? 1; - while (i < result.tokens.length && result.tokens[i].type === 'added') { - parts.push(result.tokens[i].text); - runIndices.push(comparisonIndex); - comparisonIndex += 1; - i += 1; - } - i -= 1; - const endIndex = comparisonIndex - 1; - const groupId = `comparison-group-${startIndex}-${endIndex}`; - runIndices.forEach(idx => comparisonTokenIndexToGroupId.set(idx, groupId)); - const label = parts.join(' ').trim(); - items.push({ value: groupId, label: label || '(…)', pageNumber }); - continue; - } - if (token.type !== 'removed') { - comparisonIndex += 1; - } - } - return items; - }, [result, comparisonTokenIndexToGroupId]); - - // Precompute word highlight rects by page with their token indices (no merging) - const wordHighlightMaps = useMemo(() => { - if (!result) { - return { - base: new Map(), - comparison: new Map(), - }; - } - const baseMap = new Map(); - const comparisonMap = new Map(); - - let baseIndex = 0; - let comparisonIndex = 0; - for (const token of result.tokens) { - if (token.type === 'removed') { - const meta = result.tokenMetadata.base[baseIndex]; - if (meta?.bbox) { - const list = baseMap.get(meta.page) ?? []; - list.push({ rect: meta.bbox, index: baseIndex }); - baseMap.set(meta.page, list); - } - baseIndex += 1; - } else if (token.type === 'added') { - const meta = result.tokenMetadata.comparison[comparisonIndex]; - if (meta?.bbox) { - const list = comparisonMap.get(meta.page) ?? []; - list.push({ rect: meta.bbox, index: comparisonIndex }); - comparisonMap.set(meta.page, list); - } - comparisonIndex += 1; - } else { - baseIndex += 1; - comparisonIndex += 1; - } - } - - return { base: baseMap, comparison: comparisonMap }; - }, [result]); - - // Compute a consistent per-row height so that page N in base aligns with page N in comparison - const getRowHeightPx = useCallback((pageNumber: number) => { - const basePage = basePages.find(p => p.pageNumber === pageNumber); - const compPage = comparisonPages.find(p => p.pageNumber === pageNumber); - // Row height must remain constant regardless of zoom. - const baseHeight = basePage ? basePage.height : 0; - const compHeight = compPage ? compPage.height : 0; - const rowHeight = Math.max(baseHeight, compHeight); - return Math.round(rowHeight); - }, [basePages, comparisonPages]); - - const handleChangeNavigation = useCallback((changeValue: string, pane: 'base' | 'comparison') => { - const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef; - const container = targetRef.current; - if (!container) { - return; - } - // Select ALL highlight boxes for the group id - const nodes = Array.from(container.querySelectorAll(`[data-change-id="${changeValue}"]`)) as HTMLElement[]; - if (nodes.length > 0) { - const containerRect = container.getBoundingClientRect(); - // Compute union bounding box of all nodes to center scroll - let minTop = Number.POSITIVE_INFINITY; - let minLeft = Number.POSITIVE_INFINITY; - let maxBottom = Number.NEGATIVE_INFINITY; - let maxRight = Number.NEGATIVE_INFINITY; - nodes.forEach((el) => { - const r = el.getBoundingClientRect(); - minTop = Math.min(minTop, r.top); - minLeft = Math.min(minLeft, r.left); - maxBottom = Math.max(maxBottom, r.bottom); - maxRight = Math.max(maxRight, r.right); - }); - const boxHeight = Math.max(1, maxBottom - minTop); - const boxWidth = Math.max(1, maxRight - minLeft); - const absoluteTop = minTop - containerRect.top + container.scrollTop; - const absoluteLeft = minLeft - containerRect.left + container.scrollLeft; - const desiredTop = Math.max(0, absoluteTop - (container.clientHeight - boxHeight) / 2); - const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2); - - container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'smooth' }); - - // Create per-page overlays using percentage coordinates to cover the entire group - const groupsByInner = new Map(); - nodes.forEach((el) => { - const inner = el.closest('.compare-diff-page__inner') as HTMLElement | null; - if (!inner) return; - const arr = groupsByInner.get(inner) || []; - arr.push(el as HTMLElement); - groupsByInner.set(inner, arr); - }); - groupsByInner.forEach((els, inner) => { - let minL = 100, minT = 100, maxR = 0, maxB = 0; - els.forEach((el) => { - const l = parseFloat((el as HTMLElement).style.left) || 0; // % - const t = parseFloat((el as HTMLElement).style.top) || 0; // % - const w = parseFloat((el as HTMLElement).style.width) || 0; // % - const h = parseFloat((el as HTMLElement).style.height) || 0; // % - minL = Math.min(minL, l); - minT = Math.min(minT, t); - maxR = Math.max(maxR, l + w); - maxB = Math.max(maxB, t + h); - }); - const overlay = document.createElement('span'); - overlay.className = 'compare-diff-flash-overlay'; - overlay.style.position = 'absolute'; - overlay.style.left = `${minL}%`; - overlay.style.top = `${minT}%`; - overlay.style.width = `${Math.max(0.1, maxR - minL)}%`; - overlay.style.height = `${Math.max(0.1, maxB - minT)}%`; - inner.appendChild(overlay); - window.setTimeout(() => overlay.remove(), 1600); - }); - - // Also retrigger per-rect flash for accessibility (keeps ARIA relationships intact) - nodes.forEach((el) => { - el.classList.remove('compare-diff-highlight--flash'); - }); - void container.clientWidth; // Force reflow to restart animation - nodes.forEach((el) => { - el.classList.add('compare-diff-highlight--flash'); - window.setTimeout(() => el.classList.remove('compare-diff-highlight--flash'), 1600); - }); - return; - } - }, []); - - // Drag-to-pan: adjust scroll positions directly for smooth, synced panning - // No pan/zoom handlers in simplified mode - if (!result) { - return renderUploadLayout(); + return uploadSection; } - // Safe to access result and the computed arrays below this point - const baseTitle = `${result.base.fileName} - ${basePages.length} pages`; - const comparisonTitle = `${result.comparison.fileName} - ${comparisonPages.length} pages`; - const baseDropdownPlaceholder = `Deletions (${baseWordChanges.length})`; - const comparisonDropdownPlaceholder = `Additions (${comparisonWordChanges.length})`; + 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, + }); + + const rightRailButtons = useCompareRightRailButtons({ + layout, + toggleLayout, + isPanMode, + setIsPanMode, + baseZoom, + comparisonZoom, + setBaseZoom, + setComparisonZoom, + centerPanForZoom, + clampPanForZoom, + clearScrollLinkDelta, + captureScrollLinkDelta, + isScrollLinked, + setIsScrollLinked, + zoomLimits, + }); + + useRightRailButtons(rightRailButtons); return ( @@ -1310,9 +284,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { )} - {/* Diff view only */} -
@@ -1330,22 +302,19 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { onTouchEnd={onTouchEnd} isPanMode={isPanMode} zoom={baseZoom} - pan={basePan} + pan={basePan} title={baseTitle} dropdownPlaceholder={baseDropdownPlaceholder} - changes={baseWordChanges.map(({ value, label }) => ({ value, label }))} - onNavigateChange={(value) => handleChangeNavigation(value, 'base')} + changes={mapChangesForDropdown(baseWordChanges)} + onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)} isLoading={baseLoading} processingMessage={processingMessage} emptyMessage={emptyMessage} pages={basePages} pairedPages={comparisonPages} getRowHeightPx={getRowHeightPx} - highlightColor={REMOVAL_HIGHLIGHT} - highlightOpacity={0.45} - offsetPixels={4} wordHighlightMap={wordHighlightMaps.base} - tokenIndexToGroupId={baseTokenIndexToGroupId} + tokenIndexToGroupId={tokenIndexToGroupId.base} documentLabel={baseDocumentLabel} pageLabel={pageLabel} altLabel={baseDocumentLabel} @@ -1364,22 +333,19 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { onTouchEnd={onTouchEnd} isPanMode={isPanMode} zoom={comparisonZoom} - pan={comparisonPan} + pan={comparisonPan} title={comparisonTitle} dropdownPlaceholder={comparisonDropdownPlaceholder} - changes={comparisonWordChanges.map(({ value, label }) => ({ value, label }))} - onNavigateChange={(value) => handleChangeNavigation(value, 'comparison')} + changes={mapChangesForDropdown(comparisonWordChanges)} + onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)} isLoading={comparisonLoading} processingMessage={processingMessage} emptyMessage={emptyMessage} pages={comparisonPages} pairedPages={basePages} getRowHeightPx={getRowHeightPx} - highlightColor={ADDITION_HIGHLIGHT} - highlightOpacity={0.35} - offsetPixels={2} wordHighlightMap={wordHighlightMaps.comparison} - tokenIndexToGroupId={comparisonTokenIndexToGroupId} + tokenIndexToGroupId={tokenIndexToGroupId.comparison} documentLabel={comparisonDocumentLabel} pageLabel={pageLabel} altLabel={comparisonDocumentLabel} diff --git a/frontend/src/components/tools/compare/hooks/useCompareChangeNavigation.ts b/frontend/src/components/tools/compare/hooks/useCompareChangeNavigation.ts new file mode 100644 index 000000000..56494a23b --- /dev/null +++ b/frontend/src/components/tools/compare/hooks/useCompareChangeNavigation.ts @@ -0,0 +1,126 @@ +import { RefObject, useCallback } from 'react'; + +type Pane = 'base' | 'comparison'; + +export const useCompareChangeNavigation = ( + baseScrollRef: RefObject, + comparisonScrollRef: RefObject +) => { + return useCallback( + (changeValue: string, pane: Pane, pageNumber?: number) => { + const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef; + const container = targetRef.current; + if (!container) { + return; + } + + const findNodes = (): HTMLElement[] => { + return Array.from( + container.querySelectorAll(`[data-change-id="${changeValue}"]`) + ) as HTMLElement[]; + }; + + const scrollToPageIfNeeded = () => { + if (!pageNumber) return false; + const pageEl = container.querySelector( + `.compare-diff-page[data-page-number="${pageNumber}"]` + ) as HTMLElement | null; + if (!pageEl) return false; + const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2); + container.scrollTo({ top: Math.max(0, top), behavior: 'auto' }); + return true; + }; + + let nodes = findNodes(); + if (nodes.length === 0) { + scrollToPageIfNeeded(); + } + + let attempts = 0; + const ensureAndScroll = () => { + nodes = findNodes(); + if (nodes.length === 0 && attempts < 12) { + attempts += 1; + scrollToPageIfNeeded(); + window.requestAnimationFrame(ensureAndScroll); + return; + } + if (nodes.length === 0) { + return; + } + + const containerRect = container.getBoundingClientRect(); + let minTop = Number.POSITIVE_INFINITY; + let minLeft = Number.POSITIVE_INFINITY; + let maxBottom = Number.NEGATIVE_INFINITY; + let maxRight = Number.NEGATIVE_INFINITY; + + nodes.forEach((element) => { + const rect = element.getBoundingClientRect(); + minTop = Math.min(minTop, rect.top); + minLeft = Math.min(minLeft, rect.left); + maxBottom = Math.max(maxBottom, rect.bottom); + maxRight = Math.max(maxRight, rect.right); + }); + + const boxHeight = Math.max(1, maxBottom - minTop); + const boxWidth = Math.max(1, maxRight - minLeft); + const absoluteTop = minTop - containerRect.top + container.scrollTop; + const absoluteLeft = minLeft - containerRect.left + container.scrollLeft; + const desiredTop = Math.max(0, absoluteTop - (container.clientHeight - boxHeight) / 2); + const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2); + + container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'smooth' }); + + const groupsByInner = new Map(); + nodes.forEach((element) => { + const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null; + if (!inner) return; + const list = groupsByInner.get(inner) ?? []; + list.push(element); + groupsByInner.set(inner, list); + }); + + groupsByInner.forEach((elements, inner) => { + let minL = 100; + let minT = 100; + let maxR = 0; + let maxB = 0; + elements.forEach((element) => { + const leftPercent = parseFloat(element.style.left) || 0; + const topPercent = parseFloat(element.style.top) || 0; + const widthPercent = parseFloat(element.style.width) || 0; + const heightPercent = parseFloat(element.style.height) || 0; + minL = Math.min(minL, leftPercent); + minT = Math.min(minT, topPercent); + maxR = Math.max(maxR, leftPercent + widthPercent); + maxB = Math.max(maxB, topPercent + heightPercent); + }); + const overlay = document.createElement('span'); + overlay.className = 'compare-diff-flash-overlay'; + overlay.style.position = 'absolute'; + overlay.style.left = `${minL}%`; + overlay.style.top = `${minT}%`; + overlay.style.width = `${Math.max(0.1, maxR - minL)}%`; + overlay.style.height = `${Math.max(0.1, maxB - minT)}%`; + inner.appendChild(overlay); + window.setTimeout(() => overlay.remove(), 1600); + }); + + nodes.forEach((element) => { + element.classList.remove('compare-diff-highlight--flash'); + }); + void container.clientWidth; // Force reflow + nodes.forEach((element) => { + element.classList.add('compare-diff-highlight--flash'); + window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600); + }); + }; + + ensureAndScroll(); + }, + [baseScrollRef, comparisonScrollRef] + ); +}; + +export type UseCompareChangeNavigationReturn = ReturnType; diff --git a/frontend/src/components/tools/compare/hooks/useCompareHighlights.ts b/frontend/src/components/tools/compare/hooks/useCompareHighlights.ts new file mode 100644 index 000000000..f86f5d885 --- /dev/null +++ b/frontend/src/components/tools/compare/hooks/useCompareHighlights.ts @@ -0,0 +1,162 @@ +import { useCallback, useMemo } from 'react'; +import type { + CompareDiffToken, + CompareResultData, + CompareTokenMetadata, +} from '../../../../types/compare'; +import type { CompareChangeOption } from '../../../../types/compareWorkbench'; +import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews'; +import type { WordHighlightEntry } from '../types'; + +interface TokenGroupMap { + base: Map; + comparison: Map; +} + +interface WordHighlightMaps { + base: Map; + comparison: Map; +} + +export interface UseCompareHighlightsResult { + baseWordChanges: CompareChangeOption[]; + comparisonWordChanges: CompareChangeOption[]; + tokenIndexToGroupId: TokenGroupMap; + wordHighlightMaps: WordHighlightMaps; + getRowHeightPx: (pageNumber: number) => number; +} + +const buildWordChanges = ( + tokens: CompareDiffToken[], + metadata: CompareTokenMetadata[], + targetType: 'added' | 'removed', + tokenIndexToGroupId: Map, + groupPrefix: string +): CompareChangeOption[] => { + tokenIndexToGroupId.clear(); + if (!tokens.length) return []; + + const items: CompareChangeOption[] = []; + let metadataIndex = 0; + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token.type === targetType) { + const startIndex = metadataIndex; + const parts: string[] = []; + const runIndices: number[] = []; + const pageNumber = metadata[metadataIndex]?.page ?? 1; + while (i < tokens.length && tokens[i].type === targetType) { + parts.push(tokens[i].text); + runIndices.push(metadataIndex); + metadataIndex += 1; + i += 1; + } + i -= 1; + const endIndex = metadataIndex - 1; + const groupId = `${groupPrefix}-${startIndex}-${endIndex}`; + runIndices.forEach((idx) => tokenIndexToGroupId.set(idx, groupId)); + const label = parts.join(' ').trim(); + items.push({ value: groupId, label: label || '(…)', pageNumber }); + continue; + } + if (token.type !== (targetType === 'added' ? 'removed' : 'added')) { + metadataIndex += 1; + } + } + + return items; +}; + +export const useCompareHighlights = ( + result: CompareResultData | null, + basePages: PagePreview[], + comparisonPages: PagePreview[], +): UseCompareHighlightsResult => { + const baseTokenIndexToGroupId = useMemo(() => new Map(), []); + const comparisonTokenIndexToGroupId = useMemo(() => new Map(), []); + + const baseWordChanges = useMemo(() => { + if (!result) return []; + return buildWordChanges( + result.tokens, + result.tokenMetadata.base, + 'removed', + baseTokenIndexToGroupId, + 'base-group' + ); + }, [baseTokenIndexToGroupId, result]); + + const comparisonWordChanges = useMemo(() => { + if (!result) return []; + return buildWordChanges( + result.tokens, + result.tokenMetadata.comparison, + 'added', + comparisonTokenIndexToGroupId, + 'comparison-group' + ); + }, [comparisonTokenIndexToGroupId, result]); + + const wordHighlightMaps = useMemo(() => { + if (!result) { + return { + base: new Map(), + comparison: new Map(), + }; + } + + const baseMap = new Map(); + const comparisonMap = new Map(); + + let baseIndex = 0; + let comparisonIndex = 0; + for (const token of result.tokens) { + if (token.type === 'removed') { + const meta = result.tokenMetadata.base[baseIndex]; + if (meta?.bbox) { + const list = baseMap.get(meta.page) ?? []; + list.push({ rect: meta.bbox, index: baseIndex }); + baseMap.set(meta.page, list); + } + baseIndex += 1; + } else if (token.type === 'added') { + const meta = result.tokenMetadata.comparison[comparisonIndex]; + if (meta?.bbox) { + const list = comparisonMap.get(meta.page) ?? []; + list.push({ rect: meta.bbox, index: comparisonIndex }); + comparisonMap.set(meta.page, list); + } + comparisonIndex += 1; + } else { + baseIndex += 1; + comparisonIndex += 1; + } + } + + return { base: baseMap, comparison: comparisonMap }; + }, [result]); + + const getRowHeightPx = useCallback( + (pageNumber: number) => { + const basePage = basePages.find((page) => page.pageNumber === pageNumber); + const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber); + const baseHeight = basePage ? basePage.height : 0; + const comparisonHeight = comparisonPage ? comparisonPage.height : 0; + const rowHeight = Math.max(baseHeight, comparisonHeight); + return Math.round(rowHeight); + }, + [basePages, comparisonPages] + ); + + return { + baseWordChanges, + comparisonWordChanges, + tokenIndexToGroupId: { + base: baseTokenIndexToGroupId, + comparison: comparisonTokenIndexToGroupId, + }, + wordHighlightMaps, + getRowHeightPx, + }; +}; diff --git a/frontend/src/components/tools/compare/hooks/useComparePagePreviews.ts b/frontend/src/components/tools/compare/hooks/useComparePagePreviews.ts new file mode 100644 index 000000000..8ce873359 --- /dev/null +++ b/frontend/src/components/tools/compare/hooks/useComparePagePreviews.ts @@ -0,0 +1,110 @@ +import { useEffect, useState } from 'react'; +import { pdfWorkerManager } from '../../../../services/pdfWorkerManager'; +import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews'; + +const DISPLAY_SCALE = 1; + +const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1); + +const renderPdfDocumentToImages = async (file: File): Promise => { + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { + disableAutoFetch: true, + disableStream: true, + }); + + try { + const previews: PagePreview[] = []; + const dpr = getDevicePixelRatio(); + const renderScale = Math.max(2, Math.min(3, dpr * 2)); + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber); + const displayViewport = page.getViewport({ scale: DISPLAY_SCALE }); + const renderViewport = page.getViewport({ scale: renderScale }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = Math.round(renderViewport.width); + canvas.height = Math.round(renderViewport.height); + + if (!context) { + page.cleanup(); + continue; + } + + await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise; + previews.push({ + pageNumber, + width: Math.round(displayViewport.width), + height: Math.round(displayViewport.height), + rotation: (page.rotate || 0) % 360, + url: canvas.toDataURL(), + }); + + page.cleanup(); + canvas.width = 0; + canvas.height = 0; + } + + return previews; + } finally { + pdfWorkerManager.destroyDocument(pdf); + } +}; + +interface UseComparePagePreviewsOptions { + file: File | null; + enabled: boolean; + cacheKey: number | null; +} + +export const useComparePagePreviews = ({ + file, + enabled, + cacheKey, +}: UseComparePagePreviewsOptions) => { + const [pages, setPages] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + + if (!file || !enabled) { + setPages([]); + setLoading(false); + return () => { + cancelled = true; + }; + } + + const render = async () => { + setLoading(true); + try { + const previews = await renderPdfDocumentToImages(file); + if (!cancelled) { + setPages(previews); + } + } catch (error) { + console.error('[compare] failed to render document preview', error); + if (!cancelled) { + setPages([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + render(); + + return () => { + cancelled = true; + }; + }, [file, enabled, cacheKey]); + + return { pages, loading }; +}; + +export type UseComparePagePreviewsReturn = ReturnType; diff --git a/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts new file mode 100644 index 000000000..288d057e3 --- /dev/null +++ b/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts @@ -0,0 +1,680 @@ +import { + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { + MouseEvent as ReactMouseEvent, + TouchEvent as ReactTouchEvent, + WheelEvent as ReactWheelEvent, +} from 'react'; +import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews'; + +const ZOOM_MIN = 0.5; +const ZOOM_MAX = 100000; +const ZOOM_STEP = 0.1; + +type Pane = 'base' | 'comparison'; + +interface PanState { + x: number; + y: number; +} + +interface ScrollLinkDelta { + vertical: number; + horizontal: number; +} + +interface PanDragState { + active: boolean; + source: Pane | null; + startX: number; + startY: number; + startPanX: number; + startPanY: number; + targetStartPanX: number; + targetStartPanY: number; +} + +interface PinchState { + active: boolean; + pane: Pane | null; + startDistance: number; + startZoom: number; +} + +export interface UseComparePanZoomOptions { + prefersStacked: boolean; + basePages: PagePreview[]; + comparisonPages: PagePreview[]; +} + +export interface UseComparePanZoomReturn { + layout: 'side-by-side' | 'stacked'; + setLayout: (layout: 'side-by-side' | 'stacked') => void; + toggleLayout: () => void; + baseScrollRef: RefObject; + comparisonScrollRef: RefObject; + isScrollLinked: boolean; + setIsScrollLinked: (value: boolean) => void; + captureScrollLinkDelta: () => void; + clearScrollLinkDelta: () => void; + isPanMode: boolean; + setIsPanMode: (value: boolean) => void; + baseZoom: number; + setBaseZoom: (value: number) => void; + comparisonZoom: number; + setComparisonZoom: (value: number) => void; + basePan: PanState; + comparisonPan: PanState; + centerPanForZoom: (pane: Pane, zoom: number) => void; + clampPanForZoom: (pane: Pane, zoom: number) => void; + handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void; + beginPan: (pane: Pane, event: ReactMouseEvent) => void; + continuePan: (event: ReactMouseEvent) => void; + endPan: () => void; + handleWheelZoom: (pane: Pane, event: ReactWheelEvent) => void; + onTouchStart: (pane: Pane, event: ReactTouchEvent) => void; + onTouchMove: (event: ReactTouchEvent) => void; + onTouchEnd: () => void; + zoomLimits: { min: number; max: number; step: number }; +} + +export const useComparePanZoom = ({ + basePages, + comparisonPages, + prefersStacked, +}: UseComparePanZoomOptions): UseComparePanZoomReturn => { + const baseScrollRef = useRef(null); + const comparisonScrollRef = useRef(null); + const isSyncingRef = useRef(false); + const scrollLinkDeltaRef = useRef({ vertical: 0, horizontal: 0 }); + const [isScrollLinked, setIsScrollLinked] = useState(true); + const [isPanMode, setIsPanMode] = useState(false); + const panDragRef = useRef({ + active: false, + source: null, + startX: 0, + startY: 0, + startPanX: 0, + startPanY: 0, + targetStartPanX: 0, + targetStartPanY: 0, + }); + const lastActivePaneRef = useRef('base'); + const [baseZoom, setBaseZoom] = useState(1); + const [comparisonZoom, setComparisonZoom] = useState(1); + const [basePan, setBasePan] = useState({ x: 0, y: 0 }); + const [comparisonPan, setComparisonPan] = useState({ x: 0, y: 0 }); + const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 }); + const pinchRef = useRef({ active: false, pane: null, startDistance: 0, startZoom: 1 }); + + const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side'); + const setLayout = useCallback((next: 'side-by-side' | 'stacked') => { + setLayoutState(next); + }, []); + const toggleLayout = useCallback(() => { + setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side')); + }, []); + + useEffect(() => { + setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev)); + }, [prefersStacked]); + + const getPagesForPane = useCallback( + (pane: Pane) => (pane === 'base' ? basePages : comparisonPages), + [basePages, comparisonPages] + ); + + // Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height + const rowHeights = useMemo(() => { + const allPageNumbers = Array.from( + new Set([ + ...basePages.map(p => p.pageNumber), + ...comparisonPages.map(p => p.pageNumber), + ]) + ).sort((a, b) => a - b); + + const base: number[] = []; + const comp: number[] = []; + for (const pageNumber of allPageNumbers) { + const b = basePages.find(p => p.pageNumber === pageNumber) || null; + const c = comparisonPages.find(p => p.pageNumber === pageNumber) || null; + const h = Math.round(Math.max(b?.height ?? 0, c?.height ?? 0)); + if (b) base.push(h); + if (c) comp.push(h); + if (!b && c) { + // base missing this page; still push height for mapping purposes + base.push(h); + } + if (!c && b) { + // comparison missing this page; still push height for mapping purposes + comp.push(h); + } + } + + const prefix = (arr: number[]) => { + const out: number[] = new Array(arr.length + 1); + out[0] = 0; + for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i]; + return out; + }; + + return { + base, + comp, + basePrefix: prefix(base), + compPrefix: prefix(comp), + }; + }, [basePages, comparisonPages]); + + const mapScrollTopBetweenPanes = useCallback( + (sourceTop: number, sourceIsBase: boolean): number => { + const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp; + const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base; + const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix; + const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix; + + if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop; + + // Clamp to valid range + const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1); + const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop))); + + // Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1] + let lo = 0; + let hi = srcHeights.length - 1; + while (lo < hi) { + const mid = Math.floor((lo + hi + 1) / 2); + if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1; + } + const i = lo; + const within = top - srcPrefix[i]; + const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0; + + const j = Math.min(i, dstHeights.length - 1); + const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1); + return dstTop; + }, + [rowHeights] + ); + + const getMaxCanvasSize = useCallback( + (pane: Pane) => { + const pages = getPagesForPane(pane); + const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base'); + let maxW = 0; + let maxH = 0; + for (const page of pages) { + const peer = peers.find(p => p.pageNumber === page.pageNumber); + const targetHeight = peer ? Math.max(page.height, peer.height) : page.height; + const fit = targetHeight / page.height; + const width = Math.round(page.width * fit); + const height = Math.round(targetHeight); + if (width > maxW) maxW = width; + if (height > maxH) maxH = height; + } + return { maxW, maxH }; + }, + [getPagesForPane] + ); + + const getPanBounds = useCallback( + (pane: Pane, zoomOverride?: number) => { + const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current; + const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null; + let canvasW: number | null = null; + let canvasH: number | null = null; + if (canvasEl) { + const rect = canvasEl.getBoundingClientRect(); + canvasW = Math.max(0, Math.round(rect.width)); + canvasH = Math.max(0, Math.round(rect.height)); + } + + const fallback = getMaxCanvasSize(pane); + const W = canvasW ?? fallback.maxW; + const H = canvasH ?? fallback.maxH; + const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom; + const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1)); + const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1)); + return { maxX: extraX, maxY: extraY }; + }, + [baseZoom, comparisonZoom, getMaxCanvasSize] + ); + + const getPaneRotation = useCallback( + (pane: Pane) => { + const pages = getPagesForPane(pane); + const rotation = pages[0]?.rotation ?? 0; + const normalized = ((rotation % 360) + 360) % 360; + return normalized as 0 | 90 | 180 | 270 | number; + }, + [getPagesForPane] + ); + + const mapPanBetweenOrientations = useCallback( + (source: Pane, target: Pane, sourcePan: PanState) => { + const sourceRotation = getPaneRotation(source); + const targetRotation = getPaneRotation(target); + const sourceBounds = getPanBounds(source); + const targetBounds = getPanBounds(target); + + const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1; + const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1; + + const applyRotation = (nx: number, ny: number, rotation: number) => { + const r = ((rotation % 360) + 360) % 360; + if (r === 0) return { nx, ny }; + if (r === 90) return { nx: ny, ny: -nx }; + if (r === 180) return { nx: -nx, ny: -ny }; + if (r === 270) return { nx: -ny, ny: nx }; + return { nx, ny }; + }; + + const logical = applyRotation(sx, sy, sourceRotation); + const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation); + + const targetNormX = (targetCentered.nx + 1) / 2; + const targetNormY = (targetCentered.ny + 1) / 2; + + const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX)); + const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY)); + return { x: targetX, y: targetY }; + }, + [getPaneRotation, getPanBounds] + ); + + const centerPanForZoom = useCallback( + (pane: Pane, zoomValue: number) => { + const bounds = getPanBounds(pane, zoomValue); + const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) }; + if (pane === 'base') { + setBasePan(center); + } else { + setComparisonPan(center); + } + }, + [getPanBounds] + ); + + const clampPanForZoom = useCallback( + (pane: Pane, zoomValue: number) => { + const bounds = getPanBounds(pane, zoomValue); + const current = pane === 'base' ? basePan : comparisonPan; + const clamped = { + x: Math.max(0, Math.min(bounds.maxX, current.x)), + y: Math.max(0, Math.min(bounds.maxY, current.y)), + }; + if (pane === 'base') { + setBasePan(clamped); + } else { + setComparisonPan(clamped); + } + }, + [basePan, comparisonPan, getPanBounds] + ); + + const handleScrollSync = useCallback( + (source: HTMLDivElement | null, target: HTMLDivElement | null) => { + if (panDragRef.current.active) return; + if (!source || !target || isSyncingRef.current || !isScrollLinked) { + return; + } + + lastActivePaneRef.current = source === baseScrollRef.current ? 'base' : 'comparison'; + + const sourceIsBase = source === baseScrollRef.current; + const deltaV = scrollLinkDeltaRef.current.vertical; + + const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); + const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase); + const offset = deltaV * targetVerticalRange; + const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + (sourceIsBase ? offset : -offset))); + + isSyncingRef.current = true; + target.scrollTop = desiredTop; + requestAnimationFrame(() => { + isSyncingRef.current = false; + }); + }, + [isScrollLinked, mapScrollTopBetweenPanes] + ); + + const beginPan = useCallback( + (pane: Pane, event: ReactMouseEvent) => { + if (!isPanMode) return; + const zoom = pane === 'base' ? baseZoom : comparisonZoom; + if (zoom <= 1) return; + const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current; + if (!container) return; + + const targetEl = event.target as HTMLElement | null; + const isOnImage = !!targetEl?.closest('.compare-diff-page__inner'); + if (!isOnImage) return; + + event.preventDefault(); + panDragRef.current = { + active: true, + source: pane, + startX: event.clientX, + startY: event.clientY, + startPanX: pane === 'base' ? basePan.x : comparisonPan.x, + startPanY: pane === 'base' ? basePan.y : comparisonPan.y, + targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x, + targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y, + }; + lastActivePaneRef.current = pane; + (container as HTMLDivElement).style.cursor = 'grabbing'; + }, + [isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan] + ); + + const continuePan = useCallback( + (event: ReactMouseEvent) => { + if (!isPanMode) return; + const drag = panDragRef.current; + if (!drag.active || !drag.source) return; + + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + + const isBase = drag.source === 'base'; + const bounds = getPanBounds(drag.source); + const desired = { + x: Math.max(0, Math.min(bounds.maxX, drag.startPanX - dx)), + y: Math.max(0, Math.min(bounds.maxY, drag.startPanY - dy)), + }; + + if (isScrollLinked) { + if (isBase) { + setBasePan(desired); + } else { + setComparisonPan(desired); + } + const otherPane: Pane = isBase ? 'comparison' : 'base'; + const mappedPeer = mapPanBetweenOrientations(drag.source, otherPane, desired); + const peerBounds = getPanBounds(otherPane); + const clampedPeer = { + x: Math.max(0, Math.min(peerBounds.maxX, mappedPeer.x)), + y: Math.max(0, Math.min(peerBounds.maxY, mappedPeer.y)), + }; + if (isBase) { + setComparisonPan(clampedPeer); + } else { + setBasePan(clampedPeer); + } + } else { + if (isBase) { + setBasePan(desired); + } else { + setComparisonPan(desired); + } + } + }, + [getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations] + ); + + const endPan = useCallback(() => { + const drag = panDragRef.current; + if (!drag.active) return; + const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current; + if (sourceEl) { + const zoom = drag.source === 'base' ? baseZoom : comparisonZoom; + (sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : ''; + } + panDragRef.current.active = false; + panDragRef.current.source = null; + }, [baseZoom, comparisonZoom, isPanMode]); + + const handleWheelZoom = useCallback( + (pane: Pane, event: ReactWheelEvent) => { + if (!event.ctrlKey) return; + event.preventDefault(); + const key = pane === 'base' ? 'base' : 'comparison'; + const accum = wheelZoomAccumRef.current; + const threshold = 180; + accum[key] += event.deltaY; + const steps = Math.trunc(Math.abs(accum[key]) / threshold); + if (steps <= 0) return; + const direction = accum[key] > 0 ? -1 : 1; + accum[key] = accum[key] % threshold; + const applySteps = (zoom: number) => { + let next = zoom; + for (let i = 0; i < steps; i += 1) { + next = direction > 0 + ? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2)) + : Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2)); + } + return next; + }; + if (pane === 'base') { + const prev = baseZoom; + const next = applySteps(prev); + setBaseZoom(next); + if (next < prev) { + centerPanForZoom('base', next); + } else { + clampPanForZoom('base', next); + } + } else { + const prev = comparisonZoom; + const next = applySteps(prev); + setComparisonZoom(next); + if (next < prev) { + centerPanForZoom('comparison', next); + } else { + clampPanForZoom('comparison', next); + } + } + }, + [baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom] + ); + + const onTouchStart = useCallback( + (pane: Pane, event: ReactTouchEvent) => { + if (event.touches.length === 2) { + const [t1, t2] = [event.touches[0], event.touches[1]]; + const dx = t1.clientX - t2.clientX; + const dy = t1.clientY - t2.clientY; + pinchRef.current = { + active: true, + pane, + startDistance: Math.hypot(dx, dy), + startZoom: pane === 'base' ? baseZoom : comparisonZoom, + }; + event.preventDefault(); + } else if (event.touches.length === 1) { + if (!isPanMode) return; + const zoom = pane === 'base' ? baseZoom : comparisonZoom; + if (zoom <= 1) return; + const targetEl = event.target as HTMLElement | null; + const isOnImage = !!targetEl?.closest('.compare-diff-page__inner'); + if (!isOnImage) return; + const touch = event.touches[0]; + panDragRef.current = { + active: true, + source: pane, + startX: touch.clientX, + startY: touch.clientY, + startPanX: pane === 'base' ? basePan.x : comparisonPan.x, + startPanY: pane === 'base' ? basePan.y : comparisonPan.y, + targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x, + targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y, + }; + event.preventDefault(); + } + }, + [basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode] + ); + + const onTouchMove = useCallback( + (event: ReactTouchEvent) => { + if (pinchRef.current.active && event.touches.length === 2) { + const [t1, t2] = [event.touches[0], event.touches[1]]; + const dx = t1.clientX - t2.clientX; + const dy = t1.clientY - t2.clientY; + const distance = Math.hypot(dx, dy); + const scale = distance / Math.max(1, pinchRef.current.startDistance); + const dampened = 1 + (scale - 1) * 0.6; + const pane = pinchRef.current.pane!; + const startZoom = pinchRef.current.startZoom; + const previousZoom = pane === 'base' ? baseZoom : comparisonZoom; + const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2))); + if (pane === 'base') { + setBaseZoom(nextZoom); + if (nextZoom < previousZoom) { + centerPanForZoom('base', nextZoom); + } else if (nextZoom > previousZoom) { + clampPanForZoom('base', nextZoom); + } + } else { + setComparisonZoom(nextZoom); + if (nextZoom < previousZoom) { + centerPanForZoom('comparison', nextZoom); + } else if (nextZoom > previousZoom) { + clampPanForZoom('comparison', nextZoom); + } + } + event.preventDefault(); + return; + } + + if (panDragRef.current.active && event.touches.length === 1) { + const touch = event.touches[0]; + const dx = touch.clientX - panDragRef.current.startX; + const dy = touch.clientY - panDragRef.current.startY; + const isBase = panDragRef.current.source === 'base'; + const bounds = getPanBounds(panDragRef.current.source!); + const desired = { + x: Math.max(0, Math.min(bounds.maxX, panDragRef.current.startPanX - dx)), + y: Math.max(0, Math.min(bounds.maxY, panDragRef.current.startPanY - dy)), + }; + if (isScrollLinked) { + if (isBase) { + setBasePan(desired); + } else { + setComparisonPan(desired); + } + const otherPane: Pane = isBase ? 'comparison' : 'base'; + const mappedPeer = mapPanBetweenOrientations(isBase ? 'base' : 'comparison', otherPane, desired); + const peerBounds = getPanBounds(otherPane); + const clampedPeer = { + x: Math.max(0, Math.min(peerBounds.maxX, mappedPeer.x)), + y: Math.max(0, Math.min(peerBounds.maxY, mappedPeer.y)), + }; + if (isBase) { + setComparisonPan(clampedPeer); + } else { + setBasePan(clampedPeer); + } + } else { + if (isBase) { + setBasePan(desired); + } else { + setComparisonPan(desired); + } + } + event.preventDefault(); + } + }, + [baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations] + ); + + const onTouchEnd = useCallback(() => { + pinchRef.current.active = false; + pinchRef.current.pane = null; + panDragRef.current.active = false; + }, []); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (isScrollLinked) return; + const target = event.target as HTMLElement | null; + const tag = (target?.tagName || '').toLowerCase(); + const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true'); + if (isEditable) return; + + const baseEl = baseScrollRef.current; + const compEl = comparisonScrollRef.current; + if (!baseEl || !compEl) return; + + const STEP = 80; + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const delta = event.key === 'ArrowDown' ? STEP : -STEP; + isSyncingRef.current = true; + baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight)); + compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight)); + requestAnimationFrame(() => { + isSyncingRef.current = false; + }); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [isScrollLinked]); + + const captureScrollLinkDelta = useCallback(() => { + const baseEl = baseScrollRef.current; + const compEl = comparisonScrollRef.current; + if (!baseEl || !compEl) { + scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 }; + return; + } + const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight); + const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight); + const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth); + const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth); + + const baseV = baseEl.scrollTop / baseVMax; + const compV = compEl.scrollTop / compVMax; + const baseH = baseEl.scrollLeft / baseHMax; + const compH = compEl.scrollLeft / compHMax; + + scrollLinkDeltaRef.current = { + vertical: compV - baseV, + horizontal: compH - baseH, + }; + }, []); + + const clearScrollLinkDelta = useCallback(() => { + scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 }; + }, []); + + const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []); + + return { + layout, + setLayout, + toggleLayout, + baseScrollRef, + comparisonScrollRef, + isScrollLinked, + setIsScrollLinked, + captureScrollLinkDelta, + clearScrollLinkDelta, + isPanMode, + setIsPanMode, + baseZoom, + setBaseZoom, + comparisonZoom, + setComparisonZoom, + basePan, + comparisonPan, + centerPanForZoom, + clampPanForZoom, + handleScrollSync, + beginPan, + continuePan, + endPan, + handleWheelZoom, + onTouchStart, + onTouchMove, + onTouchEnd, + zoomLimits, + }; +}; diff --git a/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx b/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx new file mode 100644 index 000000000..b3bb5f844 --- /dev/null +++ b/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx @@ -0,0 +1,192 @@ +import { useMemo } from 'react'; +import { ActionIcon } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '../../../shared/LocalIcon'; +import { Tooltip } from '../../../shared/Tooltip'; +import { alert } from '../../../toast'; +import type { ToastLocation } from '../../../toast/types'; +import type { RightRailButtonWithAction } from '../../../../hooks/useRightRailButtons'; + +type Pane = 'base' | 'comparison'; + +export interface UseCompareRightRailButtonsOptions { + layout: 'side-by-side' | 'stacked'; + toggleLayout: () => void; + isPanMode: boolean; + setIsPanMode: (value: boolean) => void; + baseZoom: number; + comparisonZoom: number; + setBaseZoom: (value: number) => void; + setComparisonZoom: (value: number) => void; + centerPanForZoom: (pane: Pane, zoom: number) => void; + clampPanForZoom: (pane: Pane, zoom: number) => void; + clearScrollLinkDelta: () => void; + captureScrollLinkDelta: () => void; + isScrollLinked: boolean; + setIsScrollLinked: (value: boolean) => void; + zoomLimits: { min: number; max: number; step: number }; +} + +export const useCompareRightRailButtons = ({ + layout, + toggleLayout, + isPanMode, + setIsPanMode, + baseZoom, + comparisonZoom, + setBaseZoom, + setComparisonZoom, + centerPanForZoom, + clampPanForZoom, + clearScrollLinkDelta, + captureScrollLinkDelta, + isScrollLinked, + setIsScrollLinked, + zoomLimits, +}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => { + const { t } = useTranslation(); + + return useMemo(() => [ + { + id: 'compare-toggle-layout', + icon: ( + + ), + tooltip: layout === 'side-by-side' + ? t('compare.actions.stackVertically', 'Stack vertically') + : t('compare.actions.placeSideBySide', 'Place side by side'), + ariaLabel: layout === 'side-by-side' + ? t('compare.actions.stackVertically', 'Stack vertically') + : t('compare.actions.placeSideBySide', 'Place side by side'), + section: 'top', + order: 10, + onClick: toggleLayout, + }, + { + id: 'compare-pan-mode', + section: 'top', + order: 12, + render: ({ disabled }: { disabled: boolean }) => ( + + setIsPanMode(!isPanMode)} + disabled={disabled} + aria-label={t('rightRail.panMode', 'Pan Mode')} + style={isPanMode ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined} + > + + + + ), + }, + { + id: 'compare-zoom-out', + icon: , + tooltip: t('compare.actions.zoomOut', 'Zoom out'), + ariaLabel: t('compare.actions.zoomOut', 'Zoom out'), + section: 'top', + order: 13, + onClick: () => { + const { min, step } = zoomLimits; + const nextBase = Math.max(min, +(baseZoom - step).toFixed(2)); + const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2)); + setBaseZoom(nextBase); + setComparisonZoom(nextComparison); + centerPanForZoom('base', nextBase); + centerPanForZoom('comparison', nextComparison); + }, + }, + { + id: 'compare-zoom-in', + icon: , + tooltip: t('compare.actions.zoomIn', 'Zoom in'), + ariaLabel: t('compare.actions.zoomIn', 'Zoom in'), + section: 'top', + order: 14, + onClick: () => { + const { max, step } = zoomLimits; + const nextBase = Math.min(max, +(baseZoom + step).toFixed(2)); + const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2)); + setBaseZoom(nextBase); + setComparisonZoom(nextComparison); + clampPanForZoom('base', nextBase); + clampPanForZoom('comparison', nextComparison); + }, + }, + { + id: 'compare-reset-view', + icon: , + tooltip: t('compare.actions.resetView', 'Reset zoom and pan'), + ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'), + section: 'top', + order: 14.5, + onClick: () => { + setBaseZoom(1); + setComparisonZoom(1); + centerPanForZoom('base', 1); + centerPanForZoom('comparison', 1); + clearScrollLinkDelta(); + }, + }, + { + id: 'compare-toggle-scroll-link', + icon: ( + + ), + tooltip: isScrollLinked + ? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan') + : t('compare.actions.linkScrollPan', 'Link scroll and pan'), + ariaLabel: isScrollLinked + ? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan') + : t('compare.actions.linkScrollPan', 'Link scroll and pan'), + section: 'top', + order: 15, + onClick: () => { + const next = !isScrollLinked; + if (next) { + captureScrollLinkDelta(); + } else { + alert({ + alertType: 'neutral', + title: t('compare.toasts.unlinkedTitle', 'Independent scroll & pan enabled'), + body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes; panning only moves the active pane.'), + durationMs: 5000, + location: 'bottom-center' as ToastLocation, + expandable: false, + }); + } + setIsScrollLinked(next); + }, + }, + ], [ + layout, + toggleLayout, + isPanMode, + setIsPanMode, + baseZoom, + comparisonZoom, + setBaseZoom, + setComparisonZoom, + centerPanForZoom, + clampPanForZoom, + clearScrollLinkDelta, + captureScrollLinkDelta, + isScrollLinked, + setIsScrollLinked, + zoomLimits, + t, + ]); +}; + +export type UseCompareRightRailButtonsReturn = ReturnType; diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index ffa4b0db6..f32a433da 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -12,6 +12,7 @@ export interface FilesStepConfig { minFiles?: number; onCollapsedClick?: () => void; isVisible?: boolean; + autoExpandNextOnFiles?: boolean; } export interface MiddleStepConfig { @@ -67,6 +68,7 @@ export interface ToolFlowConfig { */ export function createToolFlow(config: ToolFlowConfig) { const steps = createToolSteps(); + const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0; return ( @@ -83,10 +85,11 @@ export function createToolFlow(config: ToolFlowConfig) { })} {/* Middle Steps */} - {config.steps.map((stepConfig) => + {config.steps.map((stepConfig, index) => steps.create(stepConfig.title, { isVisible: stepConfig.isVisible, - isCollapsed: stepConfig.isCollapsed, + // If enabled, auto-expand the first middle step when files exist + isCollapsed: index === 0 && config.files.autoExpandNextOnFiles ? !hasFiles : stepConfig.isCollapsed, onCollapsedClick: stepConfig.onCollapsedClick, tooltip: stepConfig.tooltip }, stepConfig.content) diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index adab9beb0..c6d6983be 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -30,11 +30,11 @@ export interface CustomWorkbenchViewRegistration { workbenchId: WorkbenchType; label: string; icon?: React.ReactNode; - component: React.ComponentType<{ data: any }>; + component: React.ComponentType<{ data: unknown }>; } export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration { - data: any; + data: unknown | null; } interface ToolWorkflowContextValue extends ToolWorkflowState { @@ -79,16 +79,21 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { customWorkbenchViews: CustomWorkbenchViewInstance[]; registerCustomWorkbenchView: (view: CustomWorkbenchViewRegistration) => void; unregisterCustomWorkbenchView: (id: string) => void; - setCustomWorkbenchViewData: (id: string, data: any) => void; + setCustomWorkbenchViewData: (id: string, data: unknown) => void; clearCustomWorkbenchViewData: (id: string) => void; } // Ensure a single context instance across HMR to avoid provider/consumer mismatches const __GLOBAL_CONTEXT_KEY__ = '__ToolWorkflowContext__'; -const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as React.Context | undefined; +type ToolWorkflowGlobalThis = typeof globalThis & { + [__GLOBAL_CONTEXT_KEY__]?: React.Context; +}; + +const toolWorkflowGlobal = globalThis as ToolWorkflowGlobalThis; +const existingContext = toolWorkflowGlobal[__GLOBAL_CONTEXT_KEY__]; const ToolWorkflowContext = existingContext ?? createContext(undefined); if (!existingContext) { - (globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext; + toolWorkflowGlobal[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext; } // Provider component @@ -104,7 +109,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const [toolResetFunctions, setToolResetFunctions] = React.useState void>>({}); const [customViewRegistry, setCustomViewRegistry] = React.useState>({}); - const [customViewData, setCustomViewData] = React.useState>({}); + const [customViewData, setCustomViewData] = React.useState>({}); // Navigation actions and state are available since we're inside NavigationProvider const { actions } = useNavigationActions(); @@ -196,7 +201,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { } }, [actions, navigationState.workbench]); - const setCustomWorkbenchViewData = useCallback((id: string, data: any) => { + const setCustomWorkbenchViewData = useCallback((id: string, data: unknown) => { setCustomViewData(prev => ({ ...prev, [id]: data })); }, []); diff --git a/frontend/src/hooks/tools/compare/operationUtils.ts b/frontend/src/hooks/tools/compare/operationUtils.ts new file mode 100644 index 000000000..cc0aae4fe --- /dev/null +++ b/frontend/src/hooks/tools/compare/operationUtils.ts @@ -0,0 +1,537 @@ +import { pdfWorkerManager } from '../../../services/pdfWorkerManager'; +import { appendWord as sharedAppendWord } from '../../../utils/textDiff'; +import type { StirlingFile } from '../../../types/fileContext'; +import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { + CompareChange, + CompareDiffToken, + CompareResultData, + TokenBoundingBox, + CompareParagraph, +} from '../../../types/compare'; + +export interface TokenMetadata { + page: number; + paragraph: number; + bbox: TokenBoundingBox | null; +} + +export 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 | null = measurementContext ? new Map() : null; +let lastMeasurementFont = ''; + +const DEFAULT_CHAR_WIDTH = 1; +const DEFAULT_SPACE_WIDTH = 0.33; + +export 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 const appendWord = (existing: string, word: string) => { + if (!existing) { + return sharedAppendWord('', word); + } + return sharedAppendWord(existing, word); +}; + +export const aggregateTotals = (tokens: CompareDiffToken[]) => { + return tokens.reduce( + (totals, token) => { + if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized + 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 } + ); +}; + +export 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) { + 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 + ) { + 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 + ) { + 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; +}; + +export 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' }); +}; + +export const clamp = (value: number): number => Math.min(1, Math.max(0, value)); + +export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | undefined => { + if (typeof value === 'object' && value !== null && 'code' in value) { + const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' }).code; + return potentialCode; + } + return undefined; +}; + +export const extractContentFromPdf = async (file: StirlingFile): Promise => { + 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: PDFPageProxy = await pdfDoc.getPage(pageIndex); + const viewport = page.getViewport({ scale: 1 }); + const content: TextContent = await page.getTextContent({ + disableCombineTextItems: true, + } as Parameters[0]); + const styles: Record = content.styles ?? {}; + + 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 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; + 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; + } + + const rawText = item.str; + const totalLen = Math.max(rawText.length, 1); + const textStyle = item.fontName ? styles[item.fontName] : 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 = item.transform[4]; + const rawY = item.transform[5]; + const transformed = [ + viewport.convertToViewportPoint(rawX, rawY), + viewport.convertToViewportPoint(rawX + item.width, rawY), + viewport.convertToViewportPoint(rawX, rawY + item.height), + viewport.convertToViewportPoint(rawX + item.width, rawY + item.height), + ]; + const xs = transformed.map(([px]) => px); + const ys = transformed.map(([, py]) => py); + const left = Math.min(...xs); + const right = Math.max(...xs); + const top = Math.min(...ys); + const bottom = Math.max(...ys); + + if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) { + prevItem = item; + continue; + } + + const [baselineStart, baselineEnd, verticalEnd] = transformed; + const baselineVector: [number, number] = [ + baselineEnd[0] - baselineStart[0], + baselineEnd[1] - baselineStart[1], + ]; + const verticalVector: [number, number] = [ + verticalEnd[0] - baselineStart[0], + verticalEnd[1] - baselineStart[1], + ]; + const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]); + const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]); + const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6; + + const font = item.fontName ? styles[item.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 verticalScale = Math.min(1, Math.max(0.75, ascent + descent)); + + 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; + + let wordLeftAbs: number; + let wordRightAbs: number; + let wordTopAbs: number; + let wordBottomAbs: number; + + if (hasOrientationVectors) { + const segStart: [number, number] = [ + baselineStart[0] + baselineVector[0] * relStart, + baselineStart[1] + baselineVector[1] * relStart, + ]; + const segEnd: [number, number] = [ + baselineStart[0] + baselineVector[0] * relEnd, + baselineStart[1] + baselineVector[1] * relEnd, + ]; + const cornerPoints: Array<[number, number]> = [ + segStart, + [segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]], + [segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]], + segEnd, + ]; + const cornerXs = cornerPoints.map(([px]) => px); + const cornerYs = cornerPoints.map(([, py]) => py); + wordLeftAbs = Math.min(...cornerXs); + wordRightAbs = Math.max(...cornerXs); + wordTopAbs = Math.min(...cornerYs); + wordBottomAbs = Math.max(...cornerYs); + } else { + const segLeftAbs = left + (right - left) * relStart; + const segRightAbs = left + (right - left) * relEnd; + wordLeftAbs = Math.min(segLeftAbs, segRightAbs); + wordRightAbs = Math.max(segLeftAbs, segRightAbs); + wordTopAbs = top; + wordBottomAbs = bottom; + } + + const wordLeft = clamp(wordLeftAbs / viewport.width); + const wordRight = clamp(wordRightAbs / viewport.width); + const wordTop = clamp(wordTopAbs / viewport.height); + const wordBottom = clamp(wordBottomAbs / viewport.height); + const wordWidth = Math.max(0, wordRight - wordLeft); + let wordHeight = Math.max(0, wordBottom - wordTop); + + if (wordHeight > 0 && verticalScale < 1) { + const midY = (wordTop + wordBottom) / 2; + const shrunkHeight = Math.max(0, wordHeight * verticalScale); + const half = shrunkHeight / 2; + const newTop = clamp(midY - half); + const newBottom = clamp(midY + half); + wordHeight = Math.max(0, newBottom - newTop); + const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight); + tokens.push(normalizedWord); + metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox }); + paragraphBuffer = appendWord(paragraphBuffer, normalizedWord); + continue; + } + + const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight); + + tokens.push(normalizedWord); + metadata.push({ + page: pageIndex, + paragraph: paragraphIndex, + bbox, + }); + + paragraphBuffer = appendWord(paragraphBuffer, normalizedWord); + } + + if (isParagraphBreak(item as TextItem, prevItem)) { + if (paragraphBuffer.trim().length > 0) { + paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() }); + paragraphBuffer = ''; + } + tokens.push('\uE000PARA'); + metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null }); + paragraphIndex += 1; + } + prevItem = item as TextItem; + } + + if (paragraphBuffer.trim().length > 0) { + paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() }); + paragraphBuffer = ''; + tokens.push('\uE000PARA'); + metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null }); + } + } + return { tokens, metadata, pageSizes, paragraphs }; + } finally { + pdfWorkerManager.destroyDocument(pdfDoc); + } +}; + + diff --git a/frontend/src/hooks/tools/compare/useCompareOperation.ts b/frontend/src/hooks/tools/compare/useCompareOperation.ts index 13f278f90..1deb53da6 100644 --- a/frontend/src/hooks/tools/compare/useCompareOperation.ts +++ b/frontend/src/hooks/tools/compare/useCompareOperation.ts @@ -1,69 +1,25 @@ 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 | 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; -}; +import { + aggregateTotals, + buildChanges, + createSummaryFile, + extractContentFromPdf, + getWorkerErrorCode, +} from './operationUtils'; export interface CompareOperationHook extends ToolOperationHook { result: CompareResultData | null; @@ -76,510 +32,7 @@ const DEFAULT_WORKER_SETTINGS = { 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 => { - 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 = ((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 = item.transform[4]; - const rawY = item.transform[5]; - const transformed = [ - viewport.convertToViewportPoint(rawX, rawY), - viewport.convertToViewportPoint(rawX + item.width, rawY), - viewport.convertToViewportPoint(rawX, rawY + item.height), - viewport.convertToViewportPoint(rawX + item.width, rawY + item.height), - ]; - const xs = transformed.map(([px]) => px); - const ys = transformed.map(([, py]) => py); - const left = Math.min(...xs); - const right = Math.max(...xs); - const top = Math.min(...ys); - const bottom = Math.max(...ys); - - if ( - !Number.isFinite(left) || - !Number.isFinite(right) || - !Number.isFinite(top) || - !Number.isFinite(bottom) - ) { - prevItem = item; - continue; - } - - const [baselineStart, baselineEnd, verticalEnd] = transformed; - const baselineVector: [number, number] = [ - baselineEnd[0] - baselineStart[0], - baselineEnd[1] - baselineStart[1], - ]; - const verticalVector: [number, number] = [ - verticalEnd[0] - baselineStart[0], - verticalEnd[1] - baselineStart[1], - ]; - const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]); - const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]); - const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6; - - // Tighten vertical boxes 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 verticalScale = Math.min(1, Math.max(0.75, ascent + descent)); - - 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; - - let wordLeftAbs: number; - let wordRightAbs: number; - let wordTopAbs: number; - let wordBottomAbs: number; - - if (hasOrientationVectors) { - const segStart: [number, number] = [ - baselineStart[0] + baselineVector[0] * relStart, - baselineStart[1] + baselineVector[1] * relStart, - ]; - const segEnd: [number, number] = [ - baselineStart[0] + baselineVector[0] * relEnd, - baselineStart[1] + baselineVector[1] * relEnd, - ]; - const cornerPoints: Array<[number, number]> = [ - segStart, - [segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]], - [segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]], - segEnd, - ]; - const cornerXs = cornerPoints.map(([px]) => px); - const cornerYs = cornerPoints.map(([, py]) => py); - wordLeftAbs = Math.min(...cornerXs); - wordRightAbs = Math.max(...cornerXs); - wordTopAbs = Math.min(...cornerYs); - wordBottomAbs = Math.max(...cornerYs); - } else { - const segLeftAbs = left + (right - left) * relStart; - const segRightAbs = left + (right - left) * relEnd; - wordLeftAbs = Math.min(segLeftAbs, segRightAbs); - wordRightAbs = Math.max(segLeftAbs, segRightAbs); - wordTopAbs = top; - wordBottomAbs = bottom; - } - - let wordLeft = clamp(wordLeftAbs / viewport.width); - let wordRight = clamp(wordRightAbs / viewport.width); - let wordTop = clamp(wordTopAbs / viewport.height); - let wordBottom = clamp(wordBottomAbs / viewport.height); - const wordWidth = Math.max(0, wordRight - wordLeft); - let wordHeight = Math.max(0, wordBottom - wordTop); - - if (wordHeight > 0 && verticalScale < 1) { - const midY = (wordTop + wordBottom) / 2; - const shrunkHeight = Math.max(0, wordHeight * verticalScale); - const half = shrunkHeight / 2; - wordTop = clamp(midY - half); - wordBottom = clamp(midY + half); - wordHeight = Math.max(0, wordBottom - wordTop); - } - - const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight); - - 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); - } -}; +// extractContentFromPdf moved to utils export const useCompareOperation = (): CompareOperationHook => { const { t } = useTranslation(); @@ -659,8 +112,8 @@ export const useCompareOperation = (): CompareOperationHook => { break; case 'error': { cleanup(); - const error = new Error(message.message); - (error as any).code = message.code; + const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' } = new Error(message.message); + error.code = message.code; reject(error); break; } @@ -816,18 +269,22 @@ export const useCompareOperation = (): CompareOperationHook => { setDownloadFilename(summaryFile.name); setStatus(t('compare.status.complete', 'Comparison ready')); - } catch (error: any) { + } catch (error: unknown) { console.error('[compare] operation failed', error); - if (error?.code === 'TOO_LARGE') { + const errorCode = getWorkerErrorCode(error); + if (errorCode === 'TOO_LARGE') { setErrorMessage(warningMessages.tooLargeMessage ?? t('compare.error.generic', 'Unable to compare these files.')); - } else if (error?.code === 'EMPTY_TEXT') { + } else if (errorCode === '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.') - ); + const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.'); + if (error instanceof Error && error.message) { + setErrorMessage(error.message); + } else if (typeof error === 'string' && error.trim().length > 0) { + setErrorMessage(error); + } else { + setErrorMessage(fallbackMessage); + } } } finally { const duration = performance.now() - operationStart; diff --git a/frontend/src/hooks/useProgressivePagePreviews.ts b/frontend/src/hooks/useProgressivePagePreviews.ts new file mode 100644 index 000000000..a18f8f774 --- /dev/null +++ b/frontend/src/hooks/useProgressivePagePreviews.ts @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { pdfWorkerManager } from '../services/pdfWorkerManager'; + +// Define PagePreview type locally since it's specific to this hook +export interface PagePreview { + pageNumber: number; + width: number; + height: number; + rotation: number; + url: string; +} + +const DISPLAY_SCALE = 1; +const BATCH_SIZE = 10; // Render 10 pages at a time + +const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1); + +interface ProgressivePagePreviewsOptions { + file: File | null; + enabled: boolean; + cacheKey: number | null; + visiblePageRange?: { start: number; end: number }; // 0-based page indices +} + +interface ProgressivePagePreviewsState { + pages: PagePreview[]; + loading: boolean; + totalPages: number; + loadedPages: Set; // 0-based page indices that have been loaded + loadingPages: Set; // 0-based page indices currently being loaded +} + +export const useProgressivePagePreviews = ({ + file, + enabled, + cacheKey, + visiblePageRange, +}: ProgressivePagePreviewsOptions) => { + const [state, setState] = useState({ + pages: [], + loading: false, + totalPages: 0, + loadedPages: new Set(), + loadingPages: new Set(), + }); + + const pdfRef = useRef(null); + const abortControllerRef = useRef(null); + + const renderPageBatch = useCallback(async ( + pdf: any, + pageNumbers: number[], + signal: AbortSignal + ): Promise => { + const previews: PagePreview[] = []; + const dpr = getDevicePixelRatio(); + const renderScale = Math.max(2, Math.min(3, dpr * 2)); + + for (const pageNumber of pageNumbers) { + if (signal.aborted) break; + + try { + const page = await pdf.getPage(pageNumber); + const displayViewport = page.getViewport({ scale: DISPLAY_SCALE }); + const renderViewport = page.getViewport({ scale: renderScale }); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + canvas.width = Math.round(renderViewport.width); + canvas.height = Math.round(renderViewport.height); + + if (!context) { + page.cleanup(); + continue; + } + + await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise; + previews.push({ + pageNumber, + width: Math.round(displayViewport.width), + height: Math.round(displayViewport.height), + rotation: (page.rotate || 0) % 360, + url: canvas.toDataURL(), + }); + + page.cleanup(); + canvas.width = 0; + canvas.height = 0; + } catch (error) { + console.error(`[progressive-pages] failed to render page ${pageNumber}:`, error); + } + } + + return previews; + }, []); + + const loadPageRange = useCallback(async ( + startPage: number, + endPage: number, + signal: AbortSignal + ) => { + // Use the live PDF ref for bounds instead of possibly stale state + const totalPages = pdfRef.current?.numPages ?? state.totalPages; + if (startPage < 0 || endPage >= totalPages || startPage > endPage) { + return; + } + + // Check which pages need to be loaded + const pagesToLoad: number[] = []; + for (let i = startPage; i <= endPage; i++) { + if (!state.loadedPages.has(i) && !state.loadingPages.has(i)) { + pagesToLoad.push(i + 1); // Convert to 1-based page numbers + } + } + + if (pagesToLoad.length === 0) return; + + // Mark pages as loading + setState(prev => ({ + ...prev, + loadingPages: new Set([...prev.loadingPages, ...pagesToLoad.map(p => p - 1)]), + })); + + try { + const previews = await renderPageBatch(pdfRef.current, pagesToLoad, signal); + + if (!signal.aborted) { + setState(prev => { + const newPages = [...prev.pages]; + const newLoadedPages = new Set(prev.loadedPages); + const newLoadingPages = new Set(prev.loadingPages); + + // Add new previews and mark as loaded + for (const preview of previews) { + const pageIndex = preview.pageNumber - 1; // Convert to 0-based + newLoadedPages.add(pageIndex); + newLoadingPages.delete(pageIndex); + + // Insert preview in correct position + const insertIndex = newPages.findIndex(p => p.pageNumber > preview.pageNumber); + if (insertIndex === -1) { + newPages.push(preview); + } else { + newPages.splice(insertIndex, 0, preview); + } + } + + return { + ...prev, + pages: newPages, + loadedPages: newLoadedPages, + loadingPages: newLoadingPages, + }; + }); + } + } catch (error) { + if (!signal.aborted) { + console.error('[progressive-pages] failed to load page batch:', error); + } + } finally { + if (!signal.aborted) { + setState(prev => { + const newLoadingPages = new Set(prev.loadingPages); + pagesToLoad.forEach(p => newLoadingPages.delete(p - 1)); + return { ...prev, loadingPages: newLoadingPages }; + }); + } + } + }, [state.loadedPages, state.loadingPages, state.totalPages, renderPageBatch]); + + // Initialize PDF and load first batch + useEffect(() => { + let cancelled = false; + + if (!file || !enabled) { + setState({ + pages: [], + loading: false, + totalPages: 0, + loadedPages: new Set(), + loadingPages: new Set(), + }); + return () => { + cancelled = true; + }; + } + + const initialize = async () => { + try { + setState(prev => ({ ...prev, loading: true })); + + const arrayBuffer = await file.arrayBuffer(); + const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { + disableAutoFetch: true, + disableStream: true, + }); + + if (cancelled) { + pdfWorkerManager.destroyDocument(pdf); + return; + } + + pdfRef.current = pdf; + const totalPages = pdf.numPages; + + setState(prev => ({ + ...prev, + totalPages, + loading: false, + })); + + // Load first batch of pages using a real abort controller + const initAbort = new AbortController(); + const firstBatchEnd = Math.min(BATCH_SIZE - 1, totalPages - 1); + await loadPageRange(0, firstBatchEnd, initAbort.signal); + + } catch (error) { + console.error('[progressive-pages] failed to initialize PDF:', error); + if (!cancelled) { + setState(prev => ({ + ...prev, + loading: false, + totalPages: 0, + })); + } + } + }; + + initialize(); + + return () => { + cancelled = true; + if (pdfRef.current) { + pdfWorkerManager.destroyDocument(pdfRef.current); + pdfRef.current = null; + } + }; + }, [file, enabled, cacheKey, loadPageRange]); + + // Load pages based on visible range + useEffect(() => { + if (!visiblePageRange || state.totalPages === 0) return; + + const { start, end } = visiblePageRange; + const startPage = Math.max(0, start - 5); // Add buffer before + const endPage = Math.min(state.totalPages - 1, end + 5); // Add buffer after + + // Cancel previous loading + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + loadPageRange(startPage, endPage, abortController.signal); + + return () => { + abortController.abort(); + }; + }, [visiblePageRange, state.totalPages, loadPageRange]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + if (pdfRef.current) { + pdfWorkerManager.destroyDocument(pdfRef.current); + } + }; + }, []); + + return { + pages: state.pages, + loading: state.loading, + totalPages: state.totalPages, + loadedPages: state.loadedPages, + loadingPages: state.loadingPages, + }; +}; + +export type UseProgressivePagePreviewsReturn = ReturnType; diff --git a/frontend/src/tools/Compare.tsx b/frontend/src/tools/Compare.tsx index 515d6c69c..bb0663c55 100644 --- a/frontend/src/tools/Compare.tsx +++ b/frontend/src/tools/Compare.tsx @@ -1,7 +1,8 @@ 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 { 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 { @@ -15,12 +16,12 @@ import { 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 { useFileContext } from '../contexts/file/fileHooks'; import type { FileId } from '../types/file'; -import { createToolFlow } from '../components/tools/shared/createToolFlow'; +import type { StirlingFile } from '../types/fileContext'; import DocumentThumbnail from '../components/shared/filePreview/DocumentThumbnail'; -import { useFilesModalContext } from '../contexts/FilesModalContext'; import './compareTool.css'; +import type { CompareWorkbenchData } from '../types/compareWorkbench'; const CUSTOM_VIEW_ID = 'compareWorkbenchView'; const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const; @@ -36,8 +37,6 @@ const Compare = (props: BaseToolProps) => { clearCustomWorkbenchViewData, } = useToolWorkflow(); const { selectors } = useFileContext(); - const { actions: fileActions } = useFileActions(); - const { openFilesModal } = useFilesModalContext(); const base = useBaseTool( 'compare', @@ -59,7 +58,7 @@ const Compare = (props: BaseToolProps) => { // Use a static label at registration time to avoid re-registering on i18n changes label: 'Compare view', icon: compareIcon, - component: CompareWorkbenchView, + component: CompareWorkbenchView as any, }); return () => { @@ -69,18 +68,26 @@ const Compare = (props: BaseToolProps) => { // 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 + // 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 first = base.selectedFiles[0]?.fileId as FileId | undefined; - const second = base.selectedFiles[1]?.fileId as FileId | undefined; + const selectedIds = base.selectedFiles.map(f => f.fileId as FileId); - const nextBase: FileId | null = first ?? null; - const nextComp: FileId | null = second ?? null; + // 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); - // Removed verbose diagnostics + // 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 (params.baseFileId !== nextBase || params.comparisonFileId !== nextComp) { - base.params.setParameters((prev: any) => ({ + if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) { + base.params.setParameters(prev => ({ ...prev, baseFileId: nextBase, comparisonFileId: nextComp, @@ -105,13 +112,14 @@ const Compare = (props: BaseToolProps) => { if (matchesSelection && result && processedAt !== null && processedAt !== lastProcessedAtRef.current) { - setCustomWorkbenchViewData(CUSTOM_VIEW_ID, { + 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(() => { @@ -136,22 +144,48 @@ const Compare = (props: BaseToolProps) => { params, ]); - const handleOpenWorkbench = useCallback(() => { - navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); - }, [navigationActions]); + // const handleOpenWorkbench = useCallback(() => { + // navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); + // }, [navigationActions]); const handleExecuteCompare = useCallback(async () => { - const selected: any[] = []; + 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 } as any, - selected as any + { ...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; @@ -159,11 +193,21 @@ const Compare = (props: BaseToolProps) => { if (!stub) { return ( - + - {t(role === 'base' ? 'compare.base.placeholder' : 'compare.comparison.placeholder', role === 'base' ? 'Select a base PDF' : 'Select a comparison PDF')} + {t( + role === 'base' ? 'compare.base.placeholder' : 'compare.comparison.placeholder', + role === 'base' ? 'Select a base PDF' : 'Select a comparison PDF' + )} - + ); } @@ -177,10 +221,17 @@ const Compare = (props: BaseToolProps) => { .join(' - '); return ( - + - + @@ -191,36 +242,9 @@ const Compare = (props: BaseToolProps) => { {meta} )} - - + ); }, [params.baseFileId, params.comparisonFileId, selectors, t] @@ -231,23 +255,30 @@ const Compare = (props: BaseToolProps) => { ); 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, + isCollapsed: false, + autoExpandNextOnFiles: true, // Next step (selection) opens as soon as files exist }, steps: [ { - title: t('compare.base.label', 'Base Document'), + title: t('compare.selection.title', 'Select Base and Comparison'), isVisible: true, - content: renderSelectedFile('base'), - }, - { - title: t('compare.comparison.label', 'Comparison Document'), - isVisible: true, - content: renderSelectedFile('comparison'), + content: ( + + {renderSelectedFile('base')} + {renderSelectedFile('comparison')} + + + + + ), }, ], executeButton: { diff --git a/frontend/src/tools/ValidateSignature.tsx b/frontend/src/tools/ValidateSignature.tsx index 0932d8555..30a3a786d 100644 --- a/frontend/src/tools/ValidateSignature.tsx +++ b/frontend/src/tools/ValidateSignature.tsx @@ -39,13 +39,18 @@ const ValidateSignature = (props: BaseToolProps) => { const hasResults = operation.results.length > 0; const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage; + // Adapter to satisfy ComponentType<{ data: unknown }> + const ReportAdapter: React.ComponentType<{ data: unknown }> = ({ data }) => ( + + ); + useEffect(() => { registerCustomWorkbenchView({ id: REPORT_VIEW_ID, workbenchId: REPORT_WORKBENCH_ID, label: t('validateSignature.report.shortTitle', 'Signature Report'), icon: reportIcon, - component: ValidateSignatureReportView, + component: ReportAdapter, }); return () => { diff --git a/frontend/src/types/compareWorkbench.ts b/frontend/src/types/compareWorkbench.ts new file mode 100644 index 000000000..89b9174fd --- /dev/null +++ b/frontend/src/types/compareWorkbench.ts @@ -0,0 +1,20 @@ +import type { CompareResultData } from './compare'; +import type { FileId } from './file'; +import type { StirlingFile } from './fileContext'; + +export interface CompareWorkbenchData { + result: CompareResultData | null; + baseFileId: FileId | null; + comparisonFileId: FileId | null; + onSelectBase?: (fileId: FileId | null) => void; + onSelectComparison?: (fileId: FileId | null) => void; + isLoading?: boolean; + baseLocalFile?: StirlingFile | null; + comparisonLocalFile?: StirlingFile | null; +} + +export interface CompareChangeOption { + value: string; + label: string; + pageNumber: number; +} diff --git a/frontend/src/utils/textDiff.ts b/frontend/src/utils/textDiff.ts index 714b149b3..f1b020847 100644 --- a/frontend/src/utils/textDiff.ts +++ b/frontend/src/utils/textDiff.ts @@ -3,7 +3,7 @@ export const PARAGRAPH_SENTINEL = '\uE000¶'; export const shouldConcatWithoutSpace = (word: string) => { - return /^[.,!?;:)\]\}]/.test(word) || word.startsWith("'") || word === "'s"; + return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s"; }; export const appendWord = (existing: string, word: string) => {