combobox.resetSelectedOption(),
+ onDropdownClose: () => {
+ combobox.resetSelectedOption();
+ // Reset sticky header state when dropdown closes
+ setStickyPage(null);
+ groupOffsetsRef.current = [];
+ const viewport = viewportRef.current;
+ if (viewport) viewport.scrollTop = 0;
+ },
+ onDropdownOpen: () => {
+ // Ensure we start at the top and initialize sticky to first page
+ const viewport = viewportRef.current;
+ if (viewport) viewport.scrollTop = 0;
+ requestAnimationFrame(() => {
+ 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);
+ }
+ });
+ },
});
const sanitize = (s: string) => {
@@ -51,6 +77,11 @@ 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 normalizedChanges = useMemo(() => {
// Helper to strip localized new-line marker occurrences from labels
@@ -69,6 +100,49 @@ const CompareNavigationDropdown = ({
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
}, [changes, query, newLineLabel]);
+ useEffect(() => {
+ // Measure search height for sticky offset
+ setSearchHeight(searchRef.current?.offsetHeight ?? 0);
+ }, []);
+
+ useEffect(() => {
+ // Build offsets for group headers whenever list changes
+ const viewport = viewportRef.current;
+ if (!viewport) return;
+ const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[];
+ groupOffsetsRef.current = headers.map((el) => {
+ const text = el.textContent || '';
+ 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]);
+
+ const handleScrollPos = ({ y }: { x: number; y: number }) => {
+ const offsets = groupOffsetsRef.current;
+ if (offsets.length === 0) return;
+ // Find the last header whose top is <= scroll, so the next header replaces it
+ let low = 0;
+ let high = offsets.length - 1;
+ let idx = 0;
+ while (low <= high) {
+ const mid = (low + high) >> 1;
+ if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary
+ idx = mid;
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+ const page = offsets[idx]?.page ?? offsets[0].page;
+ if (page !== stickyPage) setStickyPage(page);
+ };
+
return (
-
- setQuery(e.currentTarget.value)} />
-
+
+
+
+ setQuery(e.currentTarget.value)} />
+
+ {stickyPage != null && (
+ {`Page ${stickyPage}`}
+ )}
+
{normalizedChanges.length > 0 ? (
- normalizedChanges.map((item) => (
- {
- onNavigate(item.value, item.pageNumber);
- combobox.closeDropdown();
- }}
- >
- {item.label}
-
- ))
+ (() => {
+ 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(
+
+ {`Page ${lastPage}`}
+
+ );
+ }
+ nodes.push(
+ {
+ onNavigate(item.value, item.pageNumber);
+ combobox.closeDropdown();
+ }}
+ >
+
+ {item.label}
+
+
+ );
+ }
+ return nodes;
+ })()
) : (
No changes found
)}
-
-
+
+
+
);
diff --git a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx
index d0bea7214..85bcb6420 100644
--- a/frontend/src/components/tools/compare/CompareWorkbenchView.tsx
+++ b/frontend/src/components/tools/compare/CompareWorkbenchView.tsx
@@ -290,6 +290,7 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
>
{
/>
({ 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 edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
@@ -382,6 +383,7 @@ export const useComparePanZoom = ({
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
};
+ edgeOverscrollRef.current[pane] = 0;
lastActivePaneRef.current = pane;
(container as HTMLDivElement).style.cursor = 'grabbing';
},
@@ -399,11 +401,56 @@ export const useComparePanZoom = ({
const isBase = drag.source === 'base';
const bounds = getPanBounds(drag.source);
+ const rawX = drag.startPanX - dx;
+ const rawY = drag.startPanY - dy;
const desired = {
- x: Math.max(0, Math.min(bounds.maxX, drag.startPanX - dx)),
- y: Math.max(0, Math.min(bounds.maxY, drag.startPanY - dy)),
+ x: Math.max(0, Math.min(bounds.maxX, rawX)),
+ y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
+ // On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
+ const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
+ if (container) {
+ const DEADZONE = 32; // pixels
+ const STEP = 48; // pixels per incremental scroll
+ let overflowY = 0;
+ if (rawY < 0) overflowY = rawY; // negative -> scroll up
+ else if (rawY > bounds.maxY) overflowY = rawY - bounds.maxY; // positive -> scroll down
+ let applyCandidate = 0;
+ if (overflowY < -DEADZONE) applyCandidate = overflowY + DEADZONE;
+ else if (overflowY > DEADZONE) applyCandidate = overflowY - DEADZONE;
+ if (applyCandidate !== 0) {
+ const key = isBase ? 'base' : 'comparison';
+ const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
+ const magnitude = Math.abs(deltaSinceLast);
+ if (magnitude >= STEP) {
+ const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
+ edgeOverscrollRef.current[key] += stepDelta;
+ const prevTop = container.scrollTop;
+ const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
+ if (nextTop !== prevTop) {
+ container.scrollTop = nextTop;
+ if (isScrollLinked) {
+ const sourceIsBase = isBase;
+ const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
+ if (target) {
+ const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
+ const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
+ const deltaPx = sourceIsBase
+ ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
+ : scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
+ const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
+ target.scrollTop = desiredTop;
+ }
+ }
+ }
+ }
+ } else {
+ // Reset accumulator when back within deadzone
+ edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
+ }
+ }
+
if (isScrollLinked) {
if (isBase) {
setBasePan(desired);
@@ -520,6 +567,7 @@ export const useComparePanZoom = ({
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
};
+ edgeOverscrollRef.current[pane] = 0;
event.preventDefault();
}
},
@@ -564,10 +612,52 @@ export const useComparePanZoom = ({
const dy = touch.clientY - panDragRef.current.startY;
const isBase = panDragRef.current.source === 'base';
const bounds = getPanBounds(panDragRef.current.source!);
+ const rawX = panDragRef.current.startPanX - dx;
+ const rawY = panDragRef.current.startPanY - dy;
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)),
+ x: Math.max(0, Math.min(bounds.maxX, rawX)),
+ y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
+
+ const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
+ if (container) {
+ const DEADZONE = 32;
+ const STEP = 48;
+ let overflowY = 0;
+ if (rawY < 0) overflowY = rawY; else if (rawY > bounds.maxY) overflowY = rawY - bounds.maxY;
+ let applyCandidate = 0;
+ if (overflowY < -DEADZONE) applyCandidate = overflowY + DEADZONE;
+ else if (overflowY > DEADZONE) applyCandidate = overflowY - DEADZONE;
+ if (applyCandidate !== 0) {
+ const key = isBase ? 'base' : 'comparison';
+ const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
+ const magnitude = Math.abs(deltaSinceLast);
+ if (magnitude >= STEP) {
+ const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
+ edgeOverscrollRef.current[key] += stepDelta;
+ const prevTop = container.scrollTop;
+ const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
+ if (nextTop !== prevTop) {
+ container.scrollTop = nextTop;
+ if (isScrollLinked) {
+ const sourceIsBase = isBase;
+ const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
+ if (target) {
+ const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
+ const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
+ const deltaPx = sourceIsBase
+ ? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
+ : scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
+ const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
+ target.scrollTop = desiredTop;
+ }
+ }
+ }
+ }
+ } else {
+ edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
+ }
+ }
if (isScrollLinked) {
if (isBase) {
setBasePan(desired);
@@ -605,6 +695,12 @@ export const useComparePanZoom = ({
panDragRef.current.active = false;
}, []);
+ // Auto-toggle Pan Mode based on zoom level
+ useEffect(() => {
+ const shouldPan = baseZoom > 1 || comparisonZoom > 1;
+ if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
+ }, [baseZoom, comparisonZoom, isPanMode]);
+
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (isScrollLinked) return;
diff --git a/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx b/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx
index b3bb5f844..62d1ab27f 100644
--- a/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx
+++ b/frontend/src/components/tools/compare/hooks/useCompareRightRailButtons.tsx
@@ -6,6 +6,7 @@ import { Tooltip } from '../../../shared/Tooltip';
import { alert } from '../../../toast';
import type { ToastLocation } from '../../../toast/types';
import type { RightRailButtonWithAction } from '../../../../hooks/useRightRailButtons';
+import { useMediaQuery } from '@mantine/hooks';
type Pane = 'base' | 'comparison';
@@ -45,6 +46,7 @@ export const useCompareRightRailButtons = ({
zoomLimits,
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
const { t } = useTranslation();
+ const isMobile = useMediaQuery('(max-width: 768px)') ?? false;
return useMemo(() => [
{
@@ -66,26 +68,6 @@ export const useCompareRightRailButtons = ({
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: ,
@@ -127,6 +109,7 @@ export const useCompareRightRailButtons = ({
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
section: 'top',
order: 14.5,
+ disabled: baseZoom === 1 && comparisonZoom === 1,
onClick: () => {
setBaseZoom(1);
setComparisonZoom(1);
@@ -157,14 +140,16 @@ export const useCompareRightRailButtons = ({
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,
- });
+ if (!isMobile) {
+ 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);
},
@@ -186,6 +171,7 @@ export const useCompareRightRailButtons = ({
setIsScrollLinked,
zoomLimits,
t,
+ isMobile,
]);
};