mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-30 20:06:30 +01:00
eager loading with batch rendering
This commit is contained in:
parent
25dc0a178f
commit
d40d53fc2b
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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 = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user