diff --git a/frontend/src/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/components/tools/compare/CompareDocumentPane.tsx index 995edfe5b..6b0b8899f 100644 --- a/frontend/src/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/components/tools/compare/CompareDocumentPane.tsx @@ -189,8 +189,7 @@ const CompareDocumentPane = ({ const wordRects = wordHighlightMap.get(page.pageNumber) ?? []; const groupedRects = new Map(); for (const { rect, metaIndex } of wordRects) { - const id = - metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`; + const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`; const current = groupedRects.get(id) ?? []; current.push(rect); groupedRects.set(id, current); diff --git a/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx b/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx index e89241678..b47c8e72e 100644 --- a/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx +++ b/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx @@ -20,29 +20,25 @@ const CompareNavigationDropdown = ({ const combobox = useCombobox({ onDropdownClose: () => { combobox.resetSelectedOption(); - // Reset sticky header state when dropdown closes - setStickyPage(null); - groupOffsetsRef.current = []; + // Cache scrollTop so we can restore on next open const viewport = viewportRef.current; - if (viewport) viewport.scrollTop = 0; + if (viewport) scrollTopRef.current = viewport.scrollTop; + setIsOpen(false); }, onDropdownOpen: () => { - // Ensure we start at the top and initialize sticky to first page - const viewport = viewportRef.current; - if (viewport) viewport.scrollTop = 0; + setIsOpen(true); + // Restore scrollTop after mount and rebuild offsets requestAnimationFrame(() => { + const viewport = viewportRef.current; + if (viewport) viewport.scrollTop = scrollTopRef.current; const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[]; - // Rebuild offsets so scrolling after reopen updates correctly groupOffsetsRef.current = headers.map((el) => { const text = el.textContent || ''; const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0; return { top: el.offsetTop, page }; }); - if (groupOffsetsRef.current.length > 0) { - setStickyPage(groupOffsetsRef.current[0].page); - } else { - setStickyPage(null); - } + // Update sticky label based on current scroll position + handleScrollPos({ x: 0, y: scrollTopRef.current }); }); }, }); @@ -78,10 +74,10 @@ const CompareNavigationDropdown = ({ const [query, setQuery] = useState(''); const viewportRef = useRef(null); - const searchRef = useRef(null); const [stickyPage, setStickyPage] = useState(null); - const [searchHeight, setSearchHeight] = useState(0); const groupOffsetsRef = useRef>([]); + const scrollTopRef = useRef(0); + const [isOpen, setIsOpen] = useState(false); const normalizedChanges = useMemo(() => { // Helper to strip localized new-line marker occurrences from labels @@ -101,12 +97,8 @@ const CompareNavigationDropdown = ({ }, [changes, query, newLineLabel]); useEffect(() => { - // Measure search height for sticky offset - setSearchHeight(searchRef.current?.offsetHeight ?? 0); - }, []); - - useEffect(() => { - // Build offsets for group headers whenever list changes + // Build offsets for group headers whenever list changes while open + if (!isOpen) return; const viewport = viewportRef.current; if (!viewport) return; const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[]; @@ -115,13 +107,9 @@ const CompareNavigationDropdown = ({ const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0; return { top: el.offsetTop, page }; }); - // Initialize sticky label - if (groupOffsetsRef.current.length > 0) { - setStickyPage(groupOffsetsRef.current[0].page); - } else { - setStickyPage(null); - } - }, [normalizedChanges]); + // Update sticky based on current scroll position + handleScrollPos({ x: 0, y: viewport.scrollTop }); + }, [normalizedChanges, isOpen]); const handleScrollPos = ({ y }: { x: number; y: number }) => { const offsets = groupOffsetsRef.current; @@ -165,60 +153,64 @@ const CompareNavigationDropdown = ({ -
- -
- setQuery(e.currentTarget.value)} - /> -
- {stickyPage != null && ( -
- {t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage} -
- )} - - {normalizedChanges.length > 0 ? ( - (() => { - const nodes: React.ReactNode[] = []; - let lastPage: number | null = null; - for (const item of normalizedChanges) { - if (item.pageNumber && item.pageNumber !== lastPage) { - lastPage = item.pageNumber; + {/* Header sits outside scroll so it stays fixed at top */} +
+ setQuery(e.currentTarget.value)} + /> +
+ {/* Lazy render the scrollable content only when open */} + {isOpen && ( +
+ + {stickyPage != null && ( +
+ {t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage} +
+ )} + + {normalizedChanges.length > 0 ? ( + (() => { + const nodes: React.ReactNode[] = []; + let lastPage: number | null = null; + for (const item of normalizedChanges) { + if (item.pageNumber && item.pageNumber !== lastPage) { + lastPage = item.pageNumber; + nodes.push( +
+ {t('compare.summary.pageLabel', 'Page')}{' '}{lastPage} +
+ ); + } nodes.push( -
{ + onNavigate(item.value, item.pageNumber); + combobox.closeDropdown(); + }} > - {t('compare.summary.pageLabel', 'Page')}{' '}{lastPage} -
+
+ {item.label} +
+ ); } - nodes.push( - { - onNavigate(item.value, item.pageNumber); - combobox.closeDropdown(); - }} - > -
- {item.label} -
-
- ); - } - return nodes; - })() - ) : ( - {t('compare.dropdown.noResults', 'No changes found')} - )} -
-
-
+ return nodes; + })() + ) : ( + {t('compare.dropdown.noResults', 'No changes found')} + )} +
+
+
+ )}
); diff --git a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx index 2771bbbed..94157951e 100644 --- a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; -import { Alert, Stack, Text } from '@mantine/core'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Alert, Progress, Stack, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useMediaQuery } from '@mantine/hooks'; import { @@ -91,13 +91,13 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { const processedAt = result?.totals.processedAt ?? null; - const { pages: basePages, loading: baseLoading } = useComparePagePreviews({ + const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({ file: baseFile, enabled: Boolean(result && baseFile), cacheKey: processedAt, }); - const { pages: comparisonPages, loading: comparisonLoading } = useComparePagePreviews({ + const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({ file: comparisonFile, enabled: Boolean(result && comparisonFile), cacheKey: processedAt, @@ -270,16 +270,57 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { useRightRailButtons(rightRailButtons); + // Rendering progress banner for very large PDFs + const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold + const showProgressBanner = useMemo(() => { + const totals = [baseTotal || basePages.length, compTotal || comparisonPages.length]; + return Math.max(...totals) >= LARGE_PAGE_THRESHOLD && (baseLoading || comparisonLoading); + }, [baseTotal, compTotal, basePages.length, comparisonPages.length, baseLoading, comparisonLoading]); + + const totalCombined = (baseTotal || basePages.length) + (compTotal || comparisonPages.length); + const renderedCombined = baseRendered + compRendered; + const progressPct = totalCombined > 0 ? Math.min(100, Math.round((renderedCombined / totalCombined) * 100)) : 0; + + const [hideBannerAfterDone, setHideBannerAfterDone] = useState(false); + const completionTimerRef = useRef(null); + + const allDone = useMemo(() => { + const baseDone = (baseTotal || basePages.length) > 0 && baseRendered >= (baseTotal || basePages.length); + const compDone = (compTotal || comparisonPages.length) > 0 && compRendered >= (compTotal || comparisonPages.length); + return baseDone && compDone; + }, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]); + + if (allDone && completionTimerRef.current == null && showProgressBanner) { + completionTimerRef.current = window.setTimeout(() => { + setHideBannerAfterDone(true); + if (completionTimerRef.current != null) { + window.clearTimeout(completionTimerRef.current); + completionTimerRef.current = null; + } + }, 3000); + } + return ( - {result.warnings.length > 0 && ( + {showProgressBanner && !hideBannerAfterDone && ( - - {result.warnings.map((warning, index) => ( - - {warning} - - ))} + + {!allDone ? ( + <> + + {t('compare.rendering.inProgress', 'One or both of these PDFs are very large, scrolling won\'t be smooth until the rendering is complete')} + + + {`${baseRendered}/${baseTotal || basePages.length} • ${compRendered}/${compTotal || comparisonPages.length} ${t('compare.rendering.pagesRendered', 'pages rendered')}`} + + + + ) : ( + <> + {t('compare.rendering.complete', 'Page rendering complete')} + + + )} )} diff --git a/frontend/src/components/tools/compare/hooks/useCompareChangeNavigation.ts b/frontend/src/components/tools/compare/hooks/useCompareChangeNavigation.ts index b4096cd6b..d3d0b920f 100644 --- a/frontend/src/components/tools/compare/hooks/useCompareChangeNavigation.ts +++ b/frontend/src/components/tools/compare/hooks/useCompareChangeNavigation.ts @@ -67,7 +67,8 @@ export const useCompareChangeNavigation = ( 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 maxTop = Math.max(0, container.scrollHeight - container.clientHeight); + const desiredTop = Math.max(0, Math.min(maxTop, absoluteTop - (container.clientHeight - boxHeight) / 2)); const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2); container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'smooth' }); @@ -92,14 +93,16 @@ export const useCompareChangeNavigation = ( const innerRect = peerInner.getBoundingClientRect(); const innerHeight = Math.max(1, innerRect.height); const absoluteTopInPage = (topPercent / 100) * innerHeight; + const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight); const peerDesiredTop = Math.max( 0, - peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2 + Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2) ); peer.scrollTo({ top: peerDesiredTop, behavior: 'smooth' }); } else if (peerPageEl) { - // Fallback: Scroll to page top - const top = Math.max(0, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)); + // Fallback: Scroll to page top (clamped) + const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight); + const top = Math.max(0, Math.min(peerMaxTop, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2))); peer.scrollTo({ top, behavior: 'smooth' }); } } diff --git a/frontend/src/components/tools/compare/hooks/useComparePagePreviews.ts b/frontend/src/components/tools/compare/hooks/useComparePagePreviews.ts index 8ce873359..bb02a36d2 100644 --- a/frontend/src/components/tools/compare/hooks/useComparePagePreviews.ts +++ b/frontend/src/components/tools/compare/hooks/useComparePagePreviews.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { pdfWorkerManager } from '../../../../services/pdfWorkerManager'; import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews'; @@ -6,7 +6,12 @@ const DISPLAY_SCALE = 1; const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1); -const renderPdfDocumentToImages = async (file: File): Promise => { +const renderPdfDocumentToImages = async ( + file: File, + onBatch?: (previews: PagePreview[]) => void, + batchSize: number = 12, + onInitTotal?: (totalPages: number) => void, +): Promise => { const arrayBuffer = await file.arrayBuffer(); const pdf = await pdfWorkerManager.createDocument(arrayBuffer, { disableAutoFetch: true, @@ -17,7 +22,9 @@ const renderPdfDocumentToImages = async (file: File): Promise => const previews: PagePreview[] = []; const dpr = getDevicePixelRatio(); const renderScale = Math.max(2, Math.min(3, dpr * 2)); + onInitTotal?.(pdf.numPages); + let batch: PagePreview[] = []; for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { const page = await pdf.getPage(pageNumber); const displayViewport = page.getViewport({ scale: DISPLAY_SCALE }); @@ -34,19 +41,28 @@ const renderPdfDocumentToImages = async (file: File): Promise => } await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise; - previews.push({ + const preview: PagePreview = { pageNumber, width: Math.round(displayViewport.width), height: Math.round(displayViewport.height), rotation: (page.rotate || 0) % 360, url: canvas.toDataURL(), - }); + }; + previews.push(preview); + if (onBatch) { + batch.push(preview); + if (batch.length >= batchSize) { + onBatch(batch); + batch = []; + } + } page.cleanup(); canvas.width = 0; canvas.height = 0; } + if (onBatch && batch.length > 0) onBatch(batch); return previews; } finally { pdfWorkerManager.destroyDocument(pdf); @@ -66,6 +82,8 @@ export const useComparePagePreviews = ({ }: UseComparePagePreviewsOptions) => { const [pages, setPages] = useState([]); const [loading, setLoading] = useState(false); + const [totalPages, setTotalPages] = useState(0); + const inFlightRef = useRef(0); useEffect(() => { let cancelled = false; @@ -81,7 +99,27 @@ export const useComparePagePreviews = ({ const render = async () => { setLoading(true); try { - const previews = await renderPdfDocumentToImages(file); + inFlightRef.current += 1; + const current = inFlightRef.current; + const previews = await renderPdfDocumentToImages( + file, + (batch) => { + if (cancelled || current !== inFlightRef.current) return; + // Stream batches into state + setPages((prev) => { + const next = [...prev]; + for (const p of batch) { + const idx = next.findIndex((x) => x.pageNumber > p.pageNumber); + if (idx === -1) next.push(p); else next.splice(idx, 0, p); + } + return next; + }); + }, + 16, + (total) => { + if (!cancelled && current === inFlightRef.current) setTotalPages(total); + } + ); if (!cancelled) { setPages(previews); } @@ -104,7 +142,7 @@ export const useComparePagePreviews = ({ }; }, [file, enabled, cacheKey]); - return { pages, loading }; + return { pages, loading, totalPages, renderedPages: pages.length }; }; export type UseComparePagePreviewsReturn = ReturnType; diff --git a/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts index 97da5c58e..4aa435e36 100644 --- a/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts +++ b/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts @@ -99,6 +99,7 @@ export const useComparePanZoom = ({ const baseScrollRef = useRef(null); const comparisonScrollRef = useRef(null); const isSyncingRef = useRef(false); + const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false }); const scrollLinkDeltaRef = useRef({ vertical: 0, horizontal: 0 }); const scrollLinkAnchorsRef = useRef({ deltaPixelsBaseToComp: 0, @@ -337,9 +338,16 @@ export const useComparePanZoom = ({ return; } - lastActivePaneRef.current = source === baseScrollRef.current ? 'base' : 'comparison'; - const sourceIsBase = source === baseScrollRef.current; + const sourceKey = sourceIsBase ? 'base' : 'comparison'; + + // Only sync if this scroll was initiated by the user (wheel/scrollbar/keyboard), + // not by our own programmatic scrolls. + if (!userScrollRef.current[sourceKey]) { + return; + } + + lastActivePaneRef.current = sourceIsBase ? 'base' : 'comparison'; const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight); const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase); @@ -360,6 +368,43 @@ export const useComparePanZoom = ({ [isScrollLinked, mapScrollTopBetweenPanes] ); + // Track user-initiated scroll state per pane + useEffect(() => { + const baseEl = baseScrollRef.current; + const compEl = comparisonScrollRef.current; + if (!baseEl || !compEl) return; + + const onUserScrollStartBase = () => { userScrollRef.current.base = true; }; + const onUserScrollStartComp = () => { userScrollRef.current.comparison = true; }; + const onUserScrollEndBase = () => { userScrollRef.current.base = false; }; + const onUserScrollEndComp = () => { userScrollRef.current.comparison = false; }; + + const addUserListeners = (el: HTMLDivElement, onStart: () => void, onEnd: () => void) => { + el.addEventListener('wheel', onStart, { passive: true }); + el.addEventListener('mousedown', onStart, { passive: true }); + el.addEventListener('touchstart', onStart, { passive: true }); + // Heuristic: clear the flag shortly after scroll events settle + let timeout: number | null = null; + const onScroll = () => { + onStart(); + if (timeout != null) window.clearTimeout(timeout); + timeout = window.setTimeout(onEnd, 120); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('wheel', onStart as any); + el.removeEventListener('mousedown', onStart as any); + el.removeEventListener('touchstart', onStart as any); + el.removeEventListener('scroll', onScroll as any); + if (timeout != null) window.clearTimeout(timeout); + }; + }; + + const cleanupBase = addUserListeners(baseEl, onUserScrollStartBase, onUserScrollEndBase); + const cleanupComp = addUserListeners(compEl, onUserScrollStartComp, onUserScrollEndComp); + return () => { cleanupBase(); cleanupComp(); }; + }, []); + const beginPan = useCallback( (pane: Pane, event: ReactMouseEvent) => { if (!isPanMode) return; diff --git a/frontend/src/hooks/tools/compare/useCompareOperation.ts b/frontend/src/hooks/tools/compare/useCompareOperation.ts index b0b210376..fc98c95fb 100644 --- a/frontend/src/hooks/tools/compare/useCompareOperation.ts +++ b/frontend/src/hooks/tools/compare/useCompareOperation.ts @@ -180,13 +180,10 @@ export const useCompareOperation = (): CompareOperationHook => { setDownloadFilename(''); const warningMessages: CompareWorkerWarnings = { - complexMessage: t( - 'compare.complex.message', - 'One or both of the provided documents are large files, accuracy of comparison may be reduced' - ), + // No accuracy warning any more tooLargeMessage: t( 'compare.large.file.message', - 'One or Both of the provided documents are too large to process' + 'These documents are very large; comparison may take several minutes. Please keep this tab open.' ), emptyTextMessage: t( 'compare.no.text.message', @@ -328,9 +325,7 @@ export const useCompareOperation = (): CompareOperationHook => { } catch (error: unknown) { console.error('[compare] operation failed', error); const errorCode = getWorkerErrorCode(error); - if (errorCode === 'TOO_LARGE') { - setErrorMessage(warningMessages.tooLargeMessage ?? t('compare.error.generic', 'Unable to compare these files.')); - } else if (errorCode === 'EMPTY_TEXT') { + if (errorCode === 'EMPTY_TEXT') { setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.')); } else { const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.'); diff --git a/frontend/src/workers/compareWorker.ts b/frontend/src/workers/compareWorker.ts index b4abd86e6..39b760fe0 100644 --- a/frontend/src/workers/compareWorker.ts +++ b/frontend/src/workers/compareWorker.ts @@ -247,13 +247,12 @@ self.onmessage = (event: MessageEvent) => { } if (baseTokens.length > maxWordThreshold || comparisonTokens.length > maxWordThreshold) { + // For compare tool, do not fail hard; warn and continue with chunked diff const response: CompareWorkerResponse = { - type: 'error', + type: 'warning', message: warnings.tooLargeMessage ?? 'Documents are too large to compare.', - code: 'TOO_LARGE', }; self.postMessage(response); - return; } const isComplex = baseTokens.length > complexThreshold || comparisonTokens.length > complexThreshold; @@ -267,9 +266,7 @@ self.onmessage = (event: MessageEvent) => { } const start = performance.now(); - const tokens = isComplex - ? chunkedDiff(baseTokens, comparisonTokens, batchSize) - : diff(baseTokens, comparisonTokens); + const tokens = chunkedDiff(baseTokens, comparisonTokens, batchSize); const durationMs = performance.now() - start; const response: CompareWorkerResponse = {