eager loading with batch rendering

This commit is contained in:
EthanHealy01 2025-10-28 16:09:34 +00:00
parent 25dc0a178f
commit d40d53fc2b
8 changed files with 227 additions and 117 deletions

View File

@ -189,8 +189,7 @@ const CompareDocumentPane = ({
const wordRects = wordHighlightMap.get(page.pageNumber) ?? [];
const groupedRects = new Map<string, TokenBoundingBox[]>();
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);

View File

@ -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<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLDivElement | null>(null);
const [stickyPage, setStickyPage] = useState<number | null>(null);
const [searchHeight, setSearchHeight] = useState(0);
const groupOffsetsRef = useRef<Array<{ top: number; page: number }>>([]);
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 = ({
</Combobox.Target>
<Combobox.Dropdown className="compare-changes-dropdown">
<div className="compare-dropdown-scrollwrap">
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
<div ref={searchRef}>
<Combobox.Search
placeholder={t('compare.dropdown.searchPlaceholder', 'Search changes...')}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
</div>
{stickyPage != null && (
<div className="compare-dropdown-sticky" style={{ top: searchHeight }}>
{t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage}
</div>
)}
<Combobox.Options className="compare-dropdown-options">
{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 */}
<div>
<Combobox.Search
placeholder={t('compare.dropdown.searchPlaceholder', 'Search changes...')}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
</div>
{/* Lazy render the scrollable content only when open */}
{isOpen && (
<div className="compare-dropdown-scrollwrap">
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
{stickyPage != null && (
<div className="compare-dropdown-sticky" style={{ top: 0 }}>
{t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage}
</div>
)}
<Combobox.Options className="compare-dropdown-options">
{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(
<div
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
key={`group-${lastPage}`}
>
{t('compare.summary.pageLabel', 'Page')}{' '}{lastPage}
</div>
);
}
nodes.push(
<div
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
key={`group-${lastPage}`}
<Combobox.Option
value={item.value}
key={item.value}
onClick={() => {
onNavigate(item.value, item.pageNumber);
combobox.closeDropdown();
}}
>
{t('compare.summary.pageLabel', 'Page')}{' '}{lastPage}
</div>
<div className="compare-dropdown-option">
<span className="compare-dropdown-option__text">{item.label}</span>
</div>
</Combobox.Option>
);
}
nodes.push(
<Combobox.Option
value={item.value}
key={item.value}
onClick={() => {
onNavigate(item.value, item.pageNumber);
combobox.closeDropdown();
}}
>
<div className="compare-dropdown-option">
<span className="compare-dropdown-option__text">{item.label}</span>
</div>
</Combobox.Option>
);
}
return nodes;
})()
) : (
<Combobox.Empty>{t('compare.dropdown.noResults', 'No changes found')}</Combobox.Empty>
)}
</Combobox.Options>
</ScrollArea.Autosize>
</div>
return nodes;
})()
) : (
<Combobox.Empty>{t('compare.dropdown.noResults', 'No changes found')}</Combobox.Empty>
)}
</Combobox.Options>
</ScrollArea.Autosize>
</div>
)}
</Combobox.Dropdown>
</Combobox>
);

View File

@ -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<number | null>(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 (
<Stack className="compare-workbench">
{result.warnings.length > 0 && (
{showProgressBanner && !hideBannerAfterDone && (
<Alert color="yellow" variant="light">
<Stack gap={4}>
{result.warnings.map((warning, index) => (
<Text key={`warning-${index}`} size="sm">
{warning}
</Text>
))}
<Stack gap={6}>
{!allDone ? (
<>
<Text size="sm">
{t('compare.rendering.inProgress', 'One or both of these PDFs are very large, scrolling won\'t be smooth until the rendering is complete')}
</Text>
<Text size="sm">
{`${baseRendered}/${baseTotal || basePages.length}${compRendered}/${compTotal || comparisonPages.length} ${t('compare.rendering.pagesRendered', 'pages rendered')}`}
</Text>
<Progress value={progressPct} animated size="sm" />
</>
) : (
<>
<Text size="sm">{t('compare.rendering.complete', 'Page rendering complete')}</Text>
<Progress value={100} size="sm" />
</>
)}
</Stack>
</Alert>
)}

View File

@ -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' });
}
}

View File

@ -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<PagePreview[]> => {
const renderPdfDocumentToImages = async (
file: File,
onBatch?: (previews: PagePreview[]) => void,
batchSize: number = 12,
onInitTotal?: (totalPages: number) => void,
): Promise<PagePreview[]> => {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
@ -17,7 +22,9 @@ const renderPdfDocumentToImages = async (file: File): Promise<PagePreview[]> =>
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<PagePreview[]> =>
}
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<PagePreview[]>([]);
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<typeof useComparePagePreviews>;

View File

@ -99,6 +99,7 @@ export const useComparePanZoom = ({
const baseScrollRef = useRef<HTMLDivElement>(null);
const comparisonScrollRef = useRef<HTMLDivElement>(null);
const isSyncingRef = useRef(false);
const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false });
const scrollLinkDeltaRef = useRef<ScrollLinkDelta>({ vertical: 0, horizontal: 0 });
const scrollLinkAnchorsRef = useRef<ScrollLinkAnchors>({
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<HTMLDivElement>) => {
if (!isPanMode) return;

View File

@ -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.');

View File

@ -247,13 +247,12 @@ self.onmessage = (event: MessageEvent<CompareWorkerRequest>) => {
}
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<CompareWorkerRequest>) => {
}
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 = {