fix scrolling sync problem

This commit is contained in:
EthanHealy01 2025-10-28 18:03:27 +00:00
parent d40d53fc2b
commit f749e9b55b
4 changed files with 250 additions and 60 deletions

View File

@ -93,13 +93,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
progress,
} as ToastInstance;
// Detect completion
// Detect completion but do not auto-flip to success.
// Callers (e.g., compare workbench) explicitly set alertType when done.
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
// On completion: finalize type as success unless explicitly provided otherwise
next.justCompleted = false;
if (!updates.alertType) {
next.alertType = 'success';
}
next.justCompleted = true;
}
return next;

View File

@ -17,6 +17,7 @@ interface CompareDocumentPaneProps {
continuePan: (event: React.MouseEvent<HTMLDivElement>) => void;
endPan: () => void;
handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
handleWheelOverscroll: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent<HTMLDivElement>) => void;
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
onTouchEnd: (event: React.TouchEvent<HTMLDivElement>) => void;
@ -86,6 +87,7 @@ const CompareDocumentPane = ({
continuePan,
endPan,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
@ -149,7 +151,7 @@ const CompareDocumentPane = ({
onMouseMove={continuePan}
onMouseUp={endPan}
onMouseLeave={endPan}
onWheel={(event) => handleWheelZoom(pane, event)}
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
onTouchStart={(event) => onTouchStart(pane, event)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}

View File

@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { Alert, Progress, Stack, Text } from '@mantine/core';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from '@mantine/hooks';
import {
@ -20,6 +20,8 @@ import { useCompareChangeNavigation } from './hooks/useCompareChangeNavigation';
import type { CompareChangeOption } from '../../../types/compareWorkbench';
import './compareView.css';
import { useCompareRightRailButtons } from './hooks/useCompareRightRailButtons';
import { alert, updateToast, updateToastProgress, dismissToast } from '../../toast';
import type { ToastLocation } from '../../toast/types';
interface CompareWorkbenchViewProps {
data: CompareWorkbenchData | null;
@ -113,6 +115,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
continuePan,
endPan,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
@ -270,18 +273,22 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
useRightRailButtons(rightRailButtons);
// Rendering progress banner for very large PDFs
// Rendering progress toast for very large PDFs
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
const totalsKnown = (baseTotal ?? 0) > 0 && (compTotal ?? 0) > 0;
const showProgressBanner = useMemo(() => {
const totals = [baseTotal || basePages.length, compTotal || comparisonPages.length];
if (!totalsKnown) return false; // avoid premature 100% before totals are known
const totals = [baseTotal!, compTotal!];
return Math.max(...totals) >= LARGE_PAGE_THRESHOLD && (baseLoading || comparisonLoading);
}, [baseTotal, compTotal, basePages.length, comparisonPages.length, baseLoading, comparisonLoading]);
}, [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]);
const totalCombined = (baseTotal || basePages.length) + (compTotal || comparisonPages.length);
const totalCombined = totalsKnown ? (baseTotal! + compTotal!) : 0;
const renderedCombined = baseRendered + compRendered;
const progressPct = totalCombined > 0 ? Math.min(100, Math.round((renderedCombined / totalCombined) * 100)) : 0;
const progressPct = totalsKnown && totalCombined > 0
? Math.min(100, Math.round((renderedCombined / totalCombined) * 100))
: 0;
const [hideBannerAfterDone, setHideBannerAfterDone] = useState(false);
const progressToastIdRef = useRef<string | null>(null);
const completionTimerRef = useRef<number | null>(null);
const allDone = useMemo(() => {
@ -290,40 +297,76 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
return baseDone && compDone;
}, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]);
if (allDone && completionTimerRef.current == null && showProgressBanner) {
completionTimerRef.current = window.setTimeout(() => {
setHideBannerAfterDone(true);
// Drive toast lifecycle and progress updates
useEffect(() => {
// No toast needed
if (!showProgressBanner) {
if (progressToastIdRef.current) {
dismissToast(progressToastIdRef.current);
progressToastIdRef.current = null;
}
return;
}
const countsText = `${baseRendered}/${baseTotal || basePages.length}${compRendered}/${compTotal || comparisonPages.length}`;
if (!allDone) {
// Create toast if missing
if (!progressToastIdRef.current) {
const id = alert({
alertType: 'neutral',
title: t('compare.rendering.inProgress', "One or both of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
durationMs: 0,
expandable: false,
progressBarPercentage: progressPct,
});
progressToastIdRef.current = id;
} else {
updateToast(progressToastIdRef.current, {
title: t('compare.rendering.inProgress', "One or both of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
alertType: 'neutral', // ensure it stays neutral until completion
});
updateToastProgress(progressToastIdRef.current, progressPct);
}
} else {
// Completed: update then auto-dismiss after 3s
if (progressToastIdRef.current) {
updateToast(progressToastIdRef.current, {
title: t('compare.rendering.complete', 'Page rendering complete'),
body: undefined,
isPersistentPopup: false,
durationMs: 3000,
});
updateToastProgress(progressToastIdRef.current, 100);
if (completionTimerRef.current != null) window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = window.setTimeout(() => {
if (progressToastIdRef.current) {
dismissToast(progressToastIdRef.current);
progressToastIdRef.current = null;
}
if (completionTimerRef.current != null) {
window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
}, 3000);
}
}
return () => {
if (completionTimerRef.current != null) {
window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
}, 3000);
}
};
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
return (
<Stack className="compare-workbench">
{showProgressBanner && !hideBannerAfterDone && (
<Alert color="yellow" variant="light">
<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>
)}
<Stack gap="lg" className="compare-workbench__content">
<div
@ -339,6 +382,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
continuePan={continuePan}
endPan={endPan}
handleWheelZoom={handleWheelZoom}
handleWheelOverscroll={handleWheelOverscroll}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
@ -371,6 +415,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
continuePan={continuePan}
endPan={endPan}
handleWheelZoom={handleWheelZoom}
handleWheelOverscroll={handleWheelOverscroll}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}

View File

@ -17,6 +17,11 @@ const ZOOM_MIN = 0.5;
const ZOOM_MAX = 100000;
const ZOOM_STEP = 0.1;
// Default structural adjustments applied to each rendered page row. These are
// refined at runtime via DOM measurements once the panes have mounted.
const DEFAULT_ROW_STRUCTURAL_EXTRA = 32;
const DEFAULT_ROW_GAP = 8;
type Pane = 'base' | 'comparison';
interface PanState {
@ -85,6 +90,7 @@ export interface UseComparePanZoomReturn {
continuePan: (event: ReactMouseEvent<HTMLDivElement>) => void;
endPan: () => void;
handleWheelZoom: (pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => void;
handleWheelOverscroll: (pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => void;
onTouchStart: (pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => void;
onTouchMove: (event: ReactTouchEvent<HTMLDivElement>) => void;
onTouchEnd: () => void;
@ -125,6 +131,8 @@ export const useComparePanZoom = ({
const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
const pinchRef = useRef<PinchState>({ active: false, pane: null, startDistance: 0, startZoom: 1 });
const edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
const [rowStructuralExtraPx, setRowStructuralExtraPx] = useState(DEFAULT_ROW_STRUCTURAL_EXTRA);
const [rowGapPx, setRowGapPx] = useState(DEFAULT_ROW_GAP);
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
@ -143,8 +151,12 @@ export const useComparePanZoom = ({
[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 canonicalLayout = useMemo(() => {
const baseMap = new Map<number, PagePreview>();
const compMap = new Map<number, PagePreview>();
for (const page of basePages) baseMap.set(page.pageNumber, page);
for (const page of comparisonPages) compMap.set(page.pageNumber, page);
const allPageNumbers = Array.from(
new Set([
...basePages.map(p => p.pageNumber),
@ -152,22 +164,72 @@ export const useComparePanZoom = ({
])
).sort((a, b) => a - b);
const rows = allPageNumbers.map(pageNumber => {
const basePage = baseMap.get(pageNumber) ?? null;
const compPage = compMap.get(pageNumber) ?? null;
const canonicalHeight = Math.max(basePage?.height ?? 0, compPage?.height ?? 0);
return {
pageNumber,
canonicalHeight,
hasBase: Boolean(basePage),
hasComparison: Boolean(compPage),
};
});
const totalCanonicalHeight = rows.reduce((sum, row) => sum + Math.round(row.canonicalHeight), 0);
return { rows, totalCanonicalHeight };
}, [basePages, comparisonPages]);
// Measure structural padding (labels, internal gaps) and the inter-row gap
// so the scroll mapper can account for real DOM layout instead of relying on
// bare page image heights.
useEffect(() => {
if (typeof window === 'undefined') return;
if (canonicalLayout.rows.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const sourceContent =
baseScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content') ??
comparisonScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content');
if (!sourceContent) return;
const style = window.getComputedStyle(sourceContent);
const gapStr = style.rowGap || style.gap;
const parsedGap = gapStr ? Number.parseFloat(gapStr) : Number.NaN;
const measuredGap = Number.isNaN(parsedGap) ? rowGapPx : Math.max(0, Math.round(parsedGap));
if (measuredGap !== rowGapPx) {
setRowGapPx(measuredGap);
}
const totalGap = Math.max(0, canonicalLayout.rows.length - 1) * measuredGap;
const contentHeight = Math.round(sourceContent.scrollHeight);
const available = contentHeight - totalGap - canonicalLayout.totalCanonicalHeight;
const candidate = canonicalLayout.rows.length > 0
? Math.max(0, Math.round(available / canonicalLayout.rows.length))
: 0;
if (Math.abs(candidate - rowStructuralExtraPx) >= 1) {
setRowStructuralExtraPx(candidate);
}
});
return () => window.cancelAnimationFrame(raf);
}, [canonicalLayout, rowGapPx, rowStructuralExtraPx, layout]);
// Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height
const rowHeights = useMemo(() => {
const totalRows = canonicalLayout.rows.length;
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);
}
for (let index = 0; index < totalRows; index += 1) {
const row = canonicalLayout.rows[index];
const canonicalHeight = Math.round(row.canonicalHeight);
const structuralHeight = Math.max(0, Math.round(canonicalHeight + rowStructuralExtraPx));
const includeGap = index < totalRows - 1 ? rowGapPx : 0;
const totalHeight = structuralHeight + includeGap;
if (row.hasBase) base.push(totalHeight);
else if (row.hasComparison) base.push(totalHeight);
if (row.hasComparison) comp.push(totalHeight);
else if (row.hasBase) comp.push(totalHeight);
}
const prefix = (arr: number[]) => {
@ -183,7 +245,7 @@ export const useComparePanZoom = ({
basePrefix: prefix(base),
compPrefix: prefix(comp),
};
}, [basePages, comparisonPages]);
}, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]);
const mapScrollTopBetweenPanes = useCallback(
(sourceTop: number, sourceIsBase: boolean): number => {
@ -357,7 +419,17 @@ export const useComparePanZoom = ({
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
const rawDesired = mappedTop + deltaPx;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, rawDesired));
// If the mapping requests a position beyond target bounds and the target is already
// at that bound, skip writing to avoid any subtle feedback that could impede
// continued scrolling in the source pane.
const atTopBound = desiredTop === 0 && target.scrollTop === 0 && rawDesired < 0;
const atBottomBound = desiredTop === targetVerticalRange && target.scrollTop === targetVerticalRange && rawDesired > targetVerticalRange;
if (atTopBound || atBottomBound) {
return;
}
isSyncingRef.current = true;
target.scrollTop = desiredTop;
@ -581,6 +653,56 @@ export const useComparePanZoom = ({
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom]
);
// When the source pane hits its scroll limit but the peer still has room,
// propagate the wheel delta to the peer so it continues following.
const handleWheelOverscroll = useCallback(
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
if (event.ctrlKey) return; // handled by zoom handler
if (!isScrollLinked) return;
const source = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
const target = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
if (!source || !target) return;
const deltaY = event.deltaY;
if (deltaY === 0) return;
const sourceMax = Math.max(0, source.scrollHeight - source.clientHeight);
const nextSource = Math.max(0, Math.min(sourceMax, source.scrollTop + deltaY));
// If the source can scroll, let the normal scroll event drive syncing
if (nextSource !== source.scrollTop) return;
// Source is at a bound; push mapped delta into the target
const sourceIsBase = pane === 'base';
// Map the desired new source position (scrollTop + deltaY) into target space
const mappedBefore = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
const mappedAfter = mapScrollTopBetweenPanes(source.scrollTop + deltaY, sourceIsBase);
const mappedDelta = mappedAfter - mappedBefore;
// Include the pixel anchor captured when linking
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const targetMax = Math.max(0, target.scrollHeight - target.clientHeight);
const desired = Math.max(0, Math.min(targetMax, target.scrollTop + (mappedDelta || deltaY)));
if (desired !== target.scrollTop) {
isSyncingRef.current = true;
// Adjust relative to mapped space to keep the anchor consistent
const anchored = Math.max(0, Math.min(targetMax, mappedBefore + deltaPx + (mappedDelta || deltaY)));
target.scrollTop = anchored;
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
event.preventDefault();
}
},
[isScrollLinked, mapScrollTopBetweenPanes]
);
const onTouchStart = useCallback(
(pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => {
if (event.touches.length === 2) {
@ -746,6 +868,29 @@ export const useComparePanZoom = ({
if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
}, [baseZoom, comparisonZoom, isPanMode]);
// When new pages render and extend scrollHeight, re-apply the mapping so
// the follower continues tracking instead of getting stuck at its prior max.
useEffect(() => {
if (!isScrollLinked) return;
const sourceIsBase = lastActivePaneRef.current === 'base';
const source = sourceIsBase ? baseScrollRef.current : comparisonScrollRef.current;
const target = sourceIsBase ? comparisonScrollRef.current : baseScrollRef.current;
if (!source || !target) return;
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
if (Math.abs(target.scrollTop - desiredTop) > 1) {
isSyncingRef.current = true;
target.scrollTop = desiredTop;
requestAnimationFrame(() => { isSyncingRef.current = false; });
}
}, [basePages.length, comparisonPages.length, isScrollLinked, mapScrollTopBetweenPanes]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (isScrollLinked) return;
@ -838,6 +983,7 @@ export const useComparePanZoom = ({
continuePan,
endPan,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,