Feature/v2/compare tool (#4751)

# Description of Changes

- Addition of the compare tool
- 
---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
EthanHealy01
2025-11-12 14:54:01 +00:00
committed by GitHub
co-authored by James Brunton
parent f22f697edc
commit a5e2b54274
49 changed files with 6651 additions and 81 deletions
@@ -10,7 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from '@app/styles/rainbow.module.css';
import { ActionIcon, ScrollArea } from '@mantine/core';
import { ToolId } from '@app/types/toolId';
import { useMediaQuery } from '@mantine/hooks';
import { useIsMobile } from '@app/hooks/useIsMobile';
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import { useTranslation } from 'react-i18next';
import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface';
@@ -26,7 +26,7 @@ export default function ToolPanel() {
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
const isMobile = useIsMobile();
const {
leftPanelView,
@@ -0,0 +1,296 @@
import { Group, Loader, Stack, Text } from '@mantine/core';
import { useMemo, useRef, useEffect } from 'react';
import type { PagePreview } from '@app/types/compare';
import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare';
import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare';
import CompareNavigationDropdown from '@app/components/tools/compare/CompareNavigationDropdown';
import { useIsMobile } from '@app/hooks/useIsMobile';
// utilities moved to compare.ts
const CompareDocumentPane = ({
pane,
layout,
scrollRef,
peerScrollRef,
handleScrollSync,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
isPanMode,
zoom,
title,
dropdownPlaceholder,
changes,
onNavigateChange,
isLoading,
processingMessage,
pages,
pairedPages,
wordHighlightMap,
metaIndexToGroupId,
documentLabel,
pageLabel,
altLabel,
onVisiblePageChange,
}: CompareDocumentPaneProps) => {
const isMobileViewport = useIsMobile();
const pairedPageMap = useMemo(() => {
const map = new Map<number, PagePreview>();
pairedPages.forEach((item) => {
map.set(item.pageNumber, item);
});
return map;
}, [pairedPages]);
const HIGHLIGHT_BG_VAR = pane === 'base' ? 'var(--spdf-compare-removed-bg)' : 'var(--spdf-compare-added-bg)';
const OFFSET_PIXELS = pane === 'base' ? 4 : 2;
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
const pagePanRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const dragRef = useRef<{ active: boolean; page: number | null; startX: number; startY: number; startPanX: number; startPanY: number }>({ active: false, page: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 });
// Track which page images have finished loading to avoid flashing between states
const imageLoadedRef = useRef<Map<number, boolean>>(new Map());
const visiblePageRafRef = useRef<number | null>(null);
const lastReportedVisiblePageRef = useRef<number | null>(null);
const pageNodesRef = useRef<HTMLElement[] | null>(null);
const groupedRectsByPage = useMemo(() => {
const out = new Map<number, Map<string, TokenBoundingBox[]>>();
for (const p of pages) {
const rects = wordHighlightMap.get(p.pageNumber) ?? [];
out.set(p.pageNumber, groupWordRects(rects, metaIndexToGroupId, pane));
}
return out;
}, [pages, wordHighlightMap, metaIndexToGroupId, pane]);
// When zoom returns to 1 (reset), clear per-page pan state so content is centered again
useEffect(() => {
if (zoom <= 1) {
pagePanRef.current.clear();
}
}, [zoom]);
return (
<div className="compare-pane">
<div className="compare-header">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">
{title}
</Text>
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
<CompareNavigationDropdown
changes={changes}
placeholder={dropdownPlaceholder ?? null}
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
onNavigate={onNavigateChange}
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
/>
)}
</Group>
</Group>
</div>
<div
ref={scrollRef}
onScroll={(event) => {
handleScrollSync(event.currentTarget, peerScrollRef.current);
// Notify parent about the currently visible page (throttled via rAF)
if (visiblePageRafRef.current != null) return;
if (!onVisiblePageChange || pages.length === 0) return;
visiblePageRafRef.current = requestAnimationFrame(() => {
const container = scrollRef.current;
if (!container) return;
const mid = container.scrollTop + container.clientHeight * 0.5;
let bestPage = pages[0]?.pageNumber ?? 1;
let bestDist = Number.POSITIVE_INFINITY;
let nodes = pageNodesRef.current;
if (!nodes || nodes.length !== pages.length) {
nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[];
pageNodesRef.current = nodes;
}
for (const el of nodes) {
const top = el.offsetTop;
const height = el.clientHeight || 1;
const center = top + height / 2;
const dist = Math.abs(center - mid);
if (dist < bestDist) {
bestDist = dist;
const attr = el.getAttribute('data-page-number');
const pn = attr ? parseInt(attr, 10) : NaN;
if (!Number.isNaN(pn)) bestPage = pn;
}
}
if (typeof onVisiblePageChange === 'function' && bestPage !== lastReportedVisiblePageRef.current) {
lastReportedVisiblePageRef.current = bestPage;
onVisiblePageChange(pane, bestPage);
}
visiblePageRafRef.current = null;
});
}}
onMouseDown={undefined}
onMouseMove={undefined}
onMouseUp={undefined}
onMouseLeave={undefined}
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
onTouchStart={(event) => onTouchStart(pane, event)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className="compare-pane__scroll"
style={{ cursor: cursorStyle }}
>
<Stack gap={zoom <= 0.6 ? 2 : zoom <= 0.85 ? 'xs' : 'sm'} className="compare-pane__content">
{isLoading && (
<Group justify="center" gap="xs" py="md">
<Loader size="sm" />
<Text size="sm">{processingMessage}</Text>
</Group>
)}
{pages.map((page) => {
const peerPage = pairedPageMap.get(page.pageNumber);
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
const metrics = computePageLayoutMetrics({
page,
peerPage: peerPage ?? null,
layout,
isMobileViewport,
scrollRefWidth: scrollRef.current?.clientWidth ?? null,
viewportWidth,
zoom,
offsetPixels: OFFSET_PIXELS,
});
const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics;
// Compute clamped pan for current zoom so content always touches edges when in bounds
const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
const maxPanX = Math.max(0, contentWidth - Math.round(containerWidth));
const maxPanY = Math.max(0, contentHeight - Math.round(containerHeight));
const clampedPanX = Math.max(0, Math.min(maxPanX, storedPan.x));
const clampedPanY = Math.max(0, Math.min(maxPanY, storedPan.y));
const groupedRects = groupedRectsByPage.get(page.pageNumber) ?? new Map();
return (
<>
<div
className="compare-diff-page"
data-page-number={page.pageNumber}
style={{ minHeight: `${containerHeight}px` }}
>
<div
className="compare-page-title"
style={{ width: `${containerWidth}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<Text size="xs" fw={600} c="dimmed" ta="center">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
</div>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={{ width: `${containerWidth}px`, height: `${containerHeight}px`, marginLeft: 'auto', marginRight: 'auto', overflow: 'hidden' }}
onMouseDown={(e) => {
if (!isPanMode || zoom <= 1) return;
dragRef.current.active = true;
dragRef.current.page = page.pageNumber;
dragRef.current.startX = e.clientX;
dragRef.current.startY = e.clientY;
const curr = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
dragRef.current.startPanX = curr.x;
dragRef.current.startPanY = curr.y;
(e.currentTarget as HTMLElement).style.cursor = 'grabbing';
e.preventDefault();
}}
onMouseMove={(e) => {
if (!dragRef.current.active || dragRef.current.page !== page.pageNumber) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
// Clamp panning based on the actual rendered content size.
// The inner layer is width/height of the container, then scaled by innerScale.
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
const maxX = Math.max(0, contentWidth - Math.round(containerWidth));
const maxY = Math.max(0, contentHeight - Math.round(containerHeight));
const candX = dragRef.current.startPanX - dx;
const candY = dragRef.current.startPanY - dy;
const next = { x: Math.max(0, Math.min(maxX, candX)), y: Math.max(0, Math.min(maxY, candY)) };
pagePanRef.current.set(page.pageNumber, next);
e.preventDefault();
}}
onMouseUp={(e) => {
if (dragRef.current.active) {
dragRef.current.active = false;
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
}
}}
onMouseLeave={(e) => {
if (dragRef.current.active) {
dragRef.current.active = false;
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
}
}}
>
<div
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
style={{
transform: `scale(${innerScale}) translate3d(${-((clampedPanX) / innerScale)}px, ${-((clampedPanY) / innerScale)}px, 0)`,
transformOrigin: 'top left'
}}
>
{/* Image layer */}
<img
src={page.url ?? ''}
alt={altLabel}
loading="lazy"
decoding="async"
className="compare-diff-page__image"
onLoad={() => {
if (!imageLoadedRef.current.get(page.pageNumber)) {
imageLoadedRef.current.set(page.pageNumber, true);
}
}}
/>
{/* Overlay loader until the page image is loaded */}
{!((imageLoadedRef.current.get(page.pageNumber) ?? false)) && (
<div className="compare-page-loader-overlay">
<Loader size="sm" />
</div>
)}
{[...groupedRects.entries()].flatMap(([id, rects]) =>
mergeConnectedRects(rects).map((rect, index) => {
const rotation = normalizeRotation(page.rotation);
const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset;
return (
<span
key={`${pane}-highlight-${page.pageNumber}-${id}-${index}`}
data-change-id={id}
className="compare-diff-highlight"
style={{
left: `${rect.left * 100}%`,
top: `${(rect.top + verticalOffset) * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
backgroundColor: HIGHLIGHT_BG_VAR,
}}
/>
);
})
)}
</div>
</div>
</div>
</>
);
})}
</Stack>
</div>
</div>
);
};
export default CompareDocumentPane;
@@ -0,0 +1,229 @@
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { NavigationDropdownProps } from '@app/types/compare';
const CompareNavigationDropdown = ({
changes,
placeholder,
className,
onNavigate,
renderedPageNumbers,
}: NavigationDropdownProps) => {
const { t } = useTranslation();
const newLineLabel = t('compare.newLine', 'new-line');
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
// Cache scrollTop so we can restore on next open
const viewport = viewportRef.current;
if (viewport) scrollTopRef.current = viewport.scrollTop;
setIsOpen(false);
},
onDropdownOpen: () => {
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[];
groupOffsetsRef.current = headers.map((el) => {
const text = el.textContent || '';
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
return { top: el.offsetTop, page };
});
// Update sticky label based on current scroll position
handleScrollPos({ x: 0, y: scrollTopRef.current });
});
},
});
const sanitize = (s: string) => {
// Normalize and remove control/separator characters without regex ranges
return s
.normalize('NFKC')
.split('')
.map(char => {
const code = char.charCodeAt(0);
// Replace control chars (0-31, 127) and special separators with space
if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) {
return ' ';
}
return char;
})
.join('')
.replace(/\s+/g, ' ')
.trim();
};
const isMeaningful = (s: string) => {
const t = sanitize(s);
// Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class.
const rx =
(() => {
try {
// Construct at runtime so old engines dont fail parse-time
return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u');
} catch {
// Fallback (no Unicode props): letters, digits, and common punctuation/symbols
return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/;
}
})();
if (!rx.test(t)) return false;
return t.length > 0;
};
const [query, setQuery] = useState('');
const viewportRef = useRef<HTMLDivElement | null>(null);
const [stickyPage, setStickyPage] = useState<number | null>(null);
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
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const stripNewLine = (s: string) =>
s
.replace(new RegExp(`\\b${esc(newLineLabel)}\\b`, 'gi'), ' ')
.replace(/\s+/g, ' ')
.trim();
const cleaned = changes
.map((c) => ({ value: c.value, label: stripNewLine(sanitize(c.label)), pageNumber: c.pageNumber }))
.filter((c) => isMeaningful(c.label) && c.label.length > 0 && c.label.toLowerCase() !== newLineLabel.toLowerCase());
const q = sanitize(query).toLowerCase();
if (!q) return cleaned;
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
}, [changes, query, newLineLabel]);
useEffect(() => {
// 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[];
groupOffsetsRef.current = headers.map((el) => {
const text = el.textContent || '';
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
return { top: el.offsetTop, page };
});
// 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;
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 (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(value) => {
const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber;
onNavigate(value, pn);
combobox.closeDropdown();
}}
// Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly
>
<Combobox.Target>
<div
className={['compare-changes-select', className].filter(Boolean).join(' ')}
onClick={() => combobox.toggleDropdown()}
>
<span className="compare-changes-select__placeholder">{placeholder}</span>
<Combobox.Chevron />
</div>
</Combobox.Target>
<Combobox.Dropdown className="compare-changes-dropdown">
{/* 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}
{renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && (
<span className="compare-dropdown-rendering-flag"> {t('compare.rendering.rendering', 'rendering')}</span>
)}
</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}
{renderedPageNumbers && !renderedPageNumbers.has(lastPage) && (
<span className="compare-dropdown-rendering-flag"> {t('compare.rendering.rendering', 'rendering')}</span>
)}
</div>
);
}
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>
)}
</Combobox.Dropdown>
</Combobox>
);
};
export default CompareNavigationDropdown;
@@ -0,0 +1,421 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@app/hooks/useIsMobile';
import {
mapChangesForDropdown,
getFileFromSelection,
getStubFromSelection,
computeShowProgressBanner,
computeProgressPct,
computeCountsText,
computeMaxSharedPages,
} from '@app/components/tools/compare/compare';
import {
CompareResultData,
CompareWorkbenchData,
} from '@app/types/compare';
import { useFileContext } from '@app/contexts/file/fileHooks';
import { useRightRailButtons } from '@app/hooks/useRightRailButtons';
import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane';
import { useComparePagePreviews } from '@app/components/tools/compare/hooks/useComparePagePreviews';
import { useComparePanZoom } from '@app/components/tools/compare/hooks/useComparePanZoom';
import { useCompareHighlights } from '@app/components/tools/compare/hooks/useCompareHighlights';
import { useCompareChangeNavigation } from '@app/components/tools/compare/hooks/useCompareChangeNavigation';
import '@app/components/tools/compare/compareView.css';
import { useCompareRightRailButtons } from '@app/components/tools/compare/hooks/useCompareRightRailButtons';
import { alert, updateToast, updateToastProgress, dismissToast } from '@app/components/toast';
import type { ToastLocation } from '@app/components/toast/types';
interface CompareWorkbenchViewProps {
data: CompareWorkbenchData | null;
}
// helpers moved to compare.ts
const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const { t } = useTranslation();
const prefersStacked = useIsMobile();
const { selectors } = useFileContext();
const result: CompareResultData | null = data?.result ?? null;
const baseFileId = data?.baseFileId ?? null;
const comparisonFileId = data?.comparisonFileId ?? null;
const isOperationLoading = data?.isLoading ?? false;
const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors);
const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors);
const baseStub = getStubFromSelection(baseFileId, selectors);
const comparisonStub = getStubFromSelection(comparisonFileId, selectors);
const processedAt = result?.totals.processedAt ?? null;
const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({
file: baseFile,
enabled: Boolean(result && baseFile),
cacheKey: processedAt,
});
const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({
file: comparisonFile,
enabled: Boolean(result && comparisonFile),
cacheKey: processedAt,
});
const {
layout,
toggleLayout,
baseScrollRef,
comparisonScrollRef,
handleScrollSync,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
isPanMode,
setIsPanMode,
baseZoom,
setBaseZoom,
comparisonZoom,
setComparisonZoom,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
clearScrollLinkDelta,
captureScrollLinkDelta,
setIsScrollLinked,
isScrollLinked,
zoomLimits,
} = useComparePanZoom({
basePages,
comparisonPages,
prefersStacked,
});
const {
baseWordChanges,
comparisonWordChanges,
metaIndexToGroupId,
wordHighlightMaps,
getRowHeightPx,
} = useCompareHighlights(result, basePages, comparisonPages);
const temporarilySuppressScrollLink = useCallback((fn: () => void, durationMs = 700) => {
const wasLinked = isScrollLinked;
if (wasLinked) setIsScrollLinked(false);
try {
fn();
} finally {
window.setTimeout(() => {
if (wasLinked) {
// recapture anchors to keep panes aligned when relinking
captureScrollLinkDelta();
setIsScrollLinked(true);
}
}, Math.max(200, durationMs));
}
}, [isScrollLinked, setIsScrollLinked, captureScrollLinkDelta]);
const handleChangeNavigation = useCompareChangeNavigation(
baseScrollRef,
comparisonScrollRef,
{ temporarilySuppressScrollLink }
);
const processingMessage = t('compare.status.processing', 'Analyzing differences...');
const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document');
const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document');
const pageLabel = t('compare.summary.pageLabel', 'Page');
// Always show the selected file names from the sidebar; they are known before diff results
const baseTitle = baseStub?.name || result?.base?.fileName || '';
const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || '';
// During diff processing, show compact spinners in the dropdown badges
const baseDropdownPlaceholder = (isOperationLoading || !result)
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.deletionsLabel', 'Deletions')} <Loader size="xs" color="currentColor" /></span>)
: t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length });
const comparisonDropdownPlaceholder = (isOperationLoading || !result)
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.additionsLabel', 'Additions')} <Loader size="xs" color="currentColor" /></span>)
: t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length });
const rightRailButtons = useCompareRightRailButtons({
layout,
toggleLayout,
isPanMode,
setIsPanMode,
baseZoom,
comparisonZoom,
setBaseZoom,
setComparisonZoom,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
clearScrollLinkDelta,
captureScrollLinkDelta,
isScrollLinked,
setIsScrollLinked,
zoomLimits,
baseScrollRef,
comparisonScrollRef,
});
useRightRailButtons(rightRailButtons);
// 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(() => (
computeShowProgressBanner(totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading, LARGE_PAGE_THRESHOLD)
), [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]);
const progressPct = computeProgressPct(totalsKnown, baseTotal, compTotal, baseRendered, compRendered);
const progressToastIdRef = useRef<string | null>(null);
const completionTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (completionTimerRef.current != null) {
window.clearTimeout(completionTimerRef.current);
completionTimerRef.current = null;
}
if (progressToastIdRef.current) {
dismissToast(progressToastIdRef.current);
progressToastIdRef.current = 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]);
// Drive toast lifecycle and progress updates
useEffect(() => {
// No toast needed
if (!showProgressBanner) {
if (progressToastIdRef.current) {
dismissToast(progressToastIdRef.current);
progressToastIdRef.current = null;
}
return;
}
const countsText = computeCountsText(
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', "At least one 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', "At least one 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;
}
};
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
// Shared page navigation state/input
const maxSharedPages = useMemo(() => (
computeMaxSharedPages(baseTotal, compTotal, basePages.length, comparisonPages.length)
), [baseTotal, compTotal, basePages.length, comparisonPages.length]);
const [pageInputValue, setPageInputValue] = useState<string>('1');
const typingTimerRef = useRef<number | null>(null);
const isTypingRef = useRef(false);
// Clamp the displayed input if max changes smaller than current
useEffect(() => {
if (!pageInputValue) return;
const n = Math.max(1, parseInt(pageInputValue, 10) || 1);
if (maxSharedPages > 0 && n > maxSharedPages) {
setPageInputValue(String(maxSharedPages));
}
}, [maxSharedPages]);
const scrollBothToPage = useCallback((pageNum: number) => {
const scrollOne = (container: HTMLDivElement | null) => {
if (!container) return false;
const pageEl = container.querySelector(`.compare-diff-page[data-page-number="${pageNum}"]`) as HTMLElement | null;
if (!pageEl) return false;
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
const desired = Math.max(0, Math.min(maxTop, pageEl.offsetTop - Math.round(container.clientHeight * 0.2)));
container.scrollTop = desired;
return true;
};
const hitBase = scrollOne(baseScrollRef.current);
const hitComp = scrollOne(comparisonScrollRef.current);
// Warn if one or both pages are not yet rendered
const baseHas = basePages.some(p => p.pageNumber === pageNum);
const compHas = comparisonPages.some(p => p.pageNumber === pageNum);
if (!baseHas || !compHas) {
alert({
alertType: 'warning',
title: t('compare.rendering.pageNotReadyTitle', 'Page not rendered yet'),
body: t('compare.rendering.pageNotReadyBody', 'Some pages are still rendering. Navigation will snap once they are ready.'),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: false,
durationMs: 2500,
});
}
return hitBase || hitComp;
}, [basePages, comparisonPages, baseScrollRef, comparisonScrollRef, t]);
const handleTypingChange = useCallback((next: string) => {
// Only digits; allow empty while editing
const digits = next.replace(/[^0-9]/g, '');
if (digits.length === 0) {
setPageInputValue('');
if (typingTimerRef.current != null) {
window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = null;
}
return;
}
const parsed = Math.max(1, parseInt(digits, 10));
const capped = maxSharedPages > 0 ? Math.min(parsed, maxSharedPages) : parsed;
const display = String(capped);
setPageInputValue(display);
isTypingRef.current = true;
if (typingTimerRef.current != null) window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = window.setTimeout(() => {
isTypingRef.current = false;
scrollBothToPage(capped);
}, 300);
}, [maxSharedPages, scrollBothToPage]);
return (
<Stack className="compare-workbench">
<Stack gap="lg" className="compare-workbench__content">
<div
className={`compare-workbench__columns ${layout === 'stacked' ? 'compare-workbench__columns--stacked' : ''}`}
>
<CompareDocumentPane
pane="base"
layout={layout}
scrollRef={baseScrollRef}
peerScrollRef={comparisonScrollRef}
handleScrollSync={handleScrollSync}
handleWheelZoom={handleWheelZoom}
handleWheelOverscroll={handleWheelOverscroll}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
isPanMode={isPanMode}
zoom={baseZoom}
title={baseTitle}
dropdownPlaceholder={baseDropdownPlaceholder}
changes={mapChangesForDropdown(baseWordChanges)}
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)}
isLoading={isOperationLoading || baseLoading}
processingMessage={processingMessage}
pages={basePages}
pairedPages={comparisonPages}
getRowHeightPx={getRowHeightPx}
wordHighlightMap={wordHighlightMaps.base}
metaIndexToGroupId={metaIndexToGroupId.base}
documentLabel={baseDocumentLabel}
pageLabel={pageLabel}
altLabel={baseDocumentLabel}
pageInputValue={pageInputValue}
onPageInputChange={handleTypingChange}
maxSharedPages={maxSharedPages}
/>
<CompareDocumentPane
pane="comparison"
layout={layout}
scrollRef={comparisonScrollRef}
peerScrollRef={baseScrollRef}
handleScrollSync={handleScrollSync}
handleWheelZoom={handleWheelZoom}
handleWheelOverscroll={handleWheelOverscroll}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
isPanMode={isPanMode}
zoom={comparisonZoom}
title={comparisonTitle}
dropdownPlaceholder={comparisonDropdownPlaceholder}
changes={mapChangesForDropdown(comparisonWordChanges)}
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)}
isLoading={isOperationLoading || comparisonLoading}
processingMessage={processingMessage}
pages={comparisonPages}
pairedPages={basePages}
getRowHeightPx={getRowHeightPx}
wordHighlightMap={wordHighlightMaps.comparison}
metaIndexToGroupId={metaIndexToGroupId.comparison}
documentLabel={comparisonDocumentLabel}
pageLabel={pageLabel}
altLabel={comparisonDocumentLabel}
pageInputValue={pageInputValue}
onPageInputChange={handleTypingChange}
maxSharedPages={maxSharedPages}
/>
</div>
</Stack>
</Stack>
);
};
export default CompareWorkbenchView;
@@ -0,0 +1,239 @@
import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare';
import type { FileId } from '@app/types/file';
import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext';
import type { PagePreview } from '@app/types/compare';
/** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */
export const toRgba = (hexColor: string, alpha: number): string => {
const hex = hexColor.replace('#', '');
if (hex.length !== 6) return hexColor;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
/** Normalize rotation to [0, 360). */
export const normalizeRotation = (deg: number | undefined | null): number => {
const n = ((deg ?? 0) % 360 + 360) % 360;
return n;
};
/**
* Merge overlapping or touching rectangles into larger non-overlapping blocks.
* Robust across rotations (vertical groups) and prevents dark spots from overlaps.
*/
export const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
if (rects.length === 0) return rects;
const EPS = 0.004; // small tolerance in normalized page coords
const sorted = rects
.slice()
.sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
const merged: TokenBoundingBox[] = [];
const overlapsOrTouches = (a: TokenBoundingBox, b: TokenBoundingBox) => {
const aR = a.left + a.width;
const aB = a.top + a.height;
const bR = b.left + b.width;
const bB = b.top + b.height;
return !(b.left > aR + EPS || bR < a.left - EPS || b.top > aB + EPS || bB < a.top - EPS);
};
for (const r of sorted) {
let mergedIntoExisting = false;
for (let i = 0; i < merged.length; i += 1) {
const m = merged[i];
if (overlapsOrTouches(m, r)) {
const left = Math.min(m.left, r.left);
const top = Math.min(m.top, r.top);
const right = Math.max(m.left + m.width, r.left + r.width);
const bottom = Math.max(m.top + m.height, r.top + r.height);
merged[i] = {
left,
top,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
};
mergedIntoExisting = true;
break;
}
}
if (!mergedIntoExisting) merged.push({ ...r });
}
return merged;
};
/** Group word rectangles by change id using metaIndexToGroupId. */
export const groupWordRects = (
wordRects: WordHighlightEntry[],
metaIndexToGroupId: Map<number, string>,
pane: 'base' | 'comparison'
): Map<string, TokenBoundingBox[]> => {
const groupedRects = new Map<string, TokenBoundingBox[]>();
for (const { rect, metaIndex } of wordRects) {
const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`;
const current = groupedRects.get(id) ?? [];
current.push(rect);
groupedRects.set(id, current);
}
return groupedRects;
};
/** Compute derived layout metrics for a page render, given environment and zoom. */
export const computePageLayoutMetrics = (args: {
page: PagePreview;
peerPage?: PagePreview | null;
layout: 'side-by-side' | 'stacked';
isMobileViewport: boolean;
scrollRefWidth: number | null;
viewportWidth: number;
zoom: number;
offsetPixels: number; // highlight offset in px relative to original page height
}) => {
const { page, peerPage, layout, isMobileViewport, scrollRefWidth, viewportWidth, zoom, offsetPixels } = args;
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
const fit = targetHeight / page.height;
const highlightOffset = offsetPixels / page.height;
const rotationNorm = normalizeRotation(page.rotation);
const isPortrait = rotationNorm === 0 || rotationNorm === 180;
const isStackedPortrait = layout === 'stacked' && isPortrait;
const containerW = scrollRefWidth ?? viewportWidth;
const stackedWidth = isMobileViewport
? Math.max(320, Math.round(containerW))
: Math.max(320, Math.round(viewportWidth * 0.5));
const stackedHeight = Math.round(stackedWidth * 1.4142);
const baseWidth = isStackedPortrait ? stackedWidth : Math.round(page.width * fit);
const baseHeight = isStackedPortrait ? stackedHeight : Math.round(targetHeight);
const containerMaxW = scrollRefWidth ?? viewportWidth;
// Container-first zooming with a stable baseline:
// Treat zoom=1 as "fit to available width" for the page's base size so
// the initial render is fully visible and centered (no cropping), regardless
// of rotation or pane/container width. When zoom < 1, shrink the container;
// when zoom > 1, keep the container at fit width and scale inner content.
const MIN_CONTAINER_WIDTH = 120;
const minScaleByWidth = MIN_CONTAINER_WIDTH / Math.max(1, baseWidth);
const fitScaleByContainer = containerMaxW / Math.max(1, baseWidth);
// Effective baseline scale used at zoom=1 (ensures at least the min width)
const baselineContainerScale = Math.max(minScaleByWidth, fitScaleByContainer);
// Lower bound the zoom so interactions remain stable
const desiredZoom = Math.max(0.1, zoom);
let containerScale: number;
let innerScale: number;
if (desiredZoom >= 1) {
// At or above baseline: keep container at fit width and scale inner content
containerScale = baselineContainerScale;
innerScale = +Math.max(0.1, desiredZoom).toFixed(4);
} else {
// Below baseline: shrink container proportionally, do not upscale inner
const scaled = baselineContainerScale * desiredZoom;
// Never smaller than minimum readable width
containerScale = Math.max(minScaleByWidth, scaled);
innerScale = 1;
}
const containerWidth = Math.max(
MIN_CONTAINER_WIDTH,
Math.min(containerMaxW, Math.round(baseWidth * containerScale))
);
const containerHeight = Math.round(baseHeight * (containerWidth / Math.max(1, baseWidth)));
return {
targetHeight,
fit,
highlightOffset,
rotationNorm,
isPortrait,
isStackedPortrait,
baseWidth,
baseHeight,
containerMaxW,
containerWidth,
containerHeight,
innerScale,
};
};
/** Map changes to dropdown options tuple. */
export const mapChangesForDropdown = (
changes: Array<{ value: string; label: string; pageNumber: number }>
) => changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber }));
/** File selection helpers */
export const getFileFromSelection = (
explicit: StirlingFile | null | undefined,
fileId: FileId | null,
selectors: { getFile: (id: FileId) => StirlingFile | undefined | null }
): StirlingFile | null => {
if (explicit) return explicit;
if (!fileId) return null;
return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null;
};
export const getStubFromSelection = (
fileId: FileId | null,
selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined }
): StirlingFileStub | null => {
if (!fileId) return null;
const stub = selectors.getStirlingFileStub(fileId);
return stub ?? null;
};
/** Progress banner computations */
export const computeShowProgressBanner = (
totalsKnown: boolean,
baseTotal: number | null | undefined,
compTotal: number | null | undefined,
baseLoading: boolean,
compLoading: boolean,
threshold: number = 400
): boolean => {
if (!totalsKnown) return false;
const totals = [baseTotal ?? 0, compTotal ?? 0];
return Math.max(...totals) >= threshold && (baseLoading || compLoading);
};
export const computeProgressPct = (
totalsKnown: boolean,
baseTotal: number | null | undefined,
compTotal: number | null | undefined,
baseRendered: number,
compRendered: number
): number => {
const totalCombined = totalsKnown ? ((baseTotal ?? 0) + (compTotal ?? 0)) : 0;
const renderedCombined = baseRendered + compRendered;
return totalsKnown && totalCombined > 0
? Math.min(100, Math.round((renderedCombined / totalCombined) * 100))
: 0;
};
export const computeCountsText = (
baseRendered: number,
baseTotal: number | null | undefined,
baseLength: number,
compRendered: number,
compTotal: number | null | undefined,
compLength: number
): string => {
const baseTotalShown = baseTotal || baseLength;
const compTotalShown = compTotal || compLength;
return `${baseRendered}/${baseTotalShown}${compRendered}/${compTotalShown}`;
};
export const computeMaxSharedPages = (
baseTotal: number | null | undefined,
compTotal: number | null | undefined,
baseLen: number,
compLen: number
): number => {
const baseMax = baseTotal || baseLen || 0;
const compMax = compTotal || compLen || 0;
const minKnown = Math.min(baseMax || Infinity, compMax || Infinity);
if (!Number.isFinite(minKnown)) return 0;
return Math.max(0, minKnown);
};
@@ -0,0 +1,515 @@
.compare-dropdown-scrollwrap {
position: relative;
}
.compare-dropdown-sticky {
position: sticky;
z-index: 2;
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-subtle);
pointer-events: none;
}
[data-mantine-color-scheme="dark"] .compare-dropdown-sticky {
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
border-bottom: 1px solid var(--border-default);
}
.compare-workbench {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
height: 100%;
min-height: 0;
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
min-width: 0;
}
.compare-workbench__content {
flex: 1;
min-height: 0;
}
.compare-pane {
display: flex;
flex-direction: column;
height: 100%;
}
.compare-pane__scroll {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.compare-pane__content {
position: relative;
}
.compare-workbench__mode {
align-self: center;
}
.compare-workbench__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: start;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
grid-auto-rows: 1fr;
}
/* Stacked mode: two rows with synchronized scroll panes */
.compare-workbench__columns--stacked {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
.compare-workbench__columns > div {
/* Critical for responsive flex children inside non-wrapping layouts */
min-height: 0;
min-width: 0;
}
.compare-legend {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
/* Sticky header styling overrides */
.compare-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-toolbar);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border-default);
padding: 0.5rem;
margin: -0.5rem -0.5rem 0.5rem -0.5rem;
}
/* Dropdown badge-like style - only style the dropdowns, not titles */
.compare-changes-select {
background: var(--spdf-compare-removed-badge-bg) !important;
color: var(--spdf-compare-removed-badge-fg) !important;
border: none !important;
border-radius: 8px !important;
font-weight: 500 !important;
cursor: pointer;
min-width: 200px;
padding: 0.375rem 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem !important;
box-sizing: border-box;
}
.compare-changes-select__placeholder {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.compare-changes-select__placeholder .mantine-Loader-root {
display: inline-flex;
margin: 0 0.125rem;
}
.compare-changes-select--comparison {
background: var(--spdf-compare-added-badge-bg) !important;
color: var(--spdf-compare-added-badge-fg) !important;
border: none !important;
border-radius: 8px !important;
font-weight: 500 !important;
}
/* Wider dropdown menu for long block text */
.compare-changes-dropdown {
min-width: 520px !important;
max-width: 70vw !important;
padding: 0 !important;
overflow: hidden; /* prevent inner elements from overflowing rounded edges */
border-radius: 8px !important; /* match dropdown container radius */
}
/* Ensure options text uses full width inside wider dropdown */
.compare-dropdown-option__text {
max-width: 100%;
}
/* Remove default padding inside Mantine options list so sticky header touches edges */
.compare-changes-dropdown .mantine-Combobox-options {
padding: 0 !important;
}
.compare-dropdown-scrollwrap { margin: 0; }
/* Ensure search field sits flush and does not overflow */
.compare-changes-dropdown .mantine-Combobox-search {
box-sizing: border-box;
width: 100% !important;
margin: 0 !important;
border-top-left-radius: 8px !important;
border-top-right-radius: 8px !important;
}
/* Style the dropdown container */
.compare-changes-select .mantine-Combobox-dropdown {
border: 1px solid var(--border-subtle) !important;
border-radius: 8px !important;
box-shadow: var(--shadow-md) !important;
background-color: var(--bg-surface) !important;
}
.compare-changes-select--comparison .mantine-Combobox-dropdown {
border: 1px solid var(--border-subtle) !important;
border-radius: 8px !important;
box-shadow: var(--shadow-md) !important;
background-color: var(--bg-surface) !important;
}
/* Custom scrollbar for ScrollArea */
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar {
width: 6px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
background: var(--bg-muted) !important;
border-radius: 3px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
background: var(--border-strong) !important;
border-radius: 3px !important;
}
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
background: var(--text-muted) !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar {
width: 6px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
background: var(--bg-muted) !important;
border-radius: 3px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
background: var(--border-strong) !important;
border-radius: 3px !important;
}
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
background: var(--text-muted) !important;
}
/* Style the dropdown options */
.compare-changes-select .mantine-Combobox-option {
font-size: 0.875rem !important;
padding: 8px 12px !important;
}
.compare-changes-select--comparison .mantine-Combobox-option {
font-size: 0.875rem !important;
padding: 8px 12px !important;
}
.compare-changes-select .mantine-Combobox-option:hover {
background-color: var(--spdf-compare-removed-badge-bg) !important;
}
.compare-changes-select--comparison .mantine-Combobox-option:hover {
background-color: var(--spdf-compare-added-badge-bg) !important;
}
/* Style the search input */
.compare-changes-select .mantine-Combobox-search {
font-size: 0.875rem !important;
padding: 8px 12px !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.compare-changes-select--comparison .mantine-Combobox-search {
font-size: 0.875rem !important;
padding: 8px 12px !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.compare-changes-select .mantine-Combobox-search::placeholder {
color: var(--text-muted) !important;
}
.compare-changes-select--comparison .mantine-Combobox-search::placeholder {
color: var(--text-muted) !important;
}
/* Style the chevron - ensure proper coloring */
.compare-changes-select .mantine-Combobox-chevron,
.compare-changes-select--comparison .mantine-Combobox-chevron {
color: inherit !important;
margin-left: 0.5rem;
}
/* Flash/pulse highlight for navigated change */
@keyframes compare-flash {
0% {
outline: 4px solid rgba(255, 235, 59, 0.0);
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
background-color: rgba(255, 235, 59, 0.2) !important;
}
25% {
outline: 4px solid rgba(255, 235, 59, 1.0);
box-shadow: 0 0 20px rgba(255, 235, 59, 0.8);
background-color: rgba(255, 235, 59, 0.4) !important;
}
50% {
outline: 4px solid rgba(255, 235, 59, 1.0);
box-shadow: 0 0 30px rgba(255, 235, 59, 0.9);
background-color: rgba(255, 235, 59, 0.5) !important;
}
75% {
outline: 4px solid rgba(255, 235, 59, 0.8);
box-shadow: 0 0 15px rgba(255, 235, 59, 0.6);
background-color: rgba(255, 235, 59, 0.3) !important;
}
100% {
outline: 4px solid rgba(255, 235, 59, 0.0);
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
background-color: rgba(255, 235, 59, 0.0) !important;
}
}
.compare-diff-highlight--flash {
animation: compare-flash 1.5s ease-in-out 1;
z-index: 1000;
position: relative;
/* Bonus: temporarily override red/green to yellow during flash for clarity */
background-color: rgba(255, 235, 59, 0.5) !important;
}
/* Union overlay for group flash */
.compare-diff-flash-overlay {
animation: compare-flash 1.5s ease-in-out 1;
z-index: 999;
background-color: rgba(255, 235, 59, 0.4);
pointer-events: none;
border-radius: 2px;
}
.compare-legend__item {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--mantine-color-dimmed);
}
.compare-legend__swatch {
width: 0.75rem;
height: 0.75rem;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.15);
}
.compare-summary__stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.compare-summary__stat-card {
flex: 1;
min-width: 12rem;
}
.compare-summary__segment {
border: 1px solid var(--mantine-color-gray-3);
border-radius: 0.5rem;
padding: 0.75rem;
background-color: var(--mantine-color-gray-0);
}
.compare-diff-page {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.compare-diff-page__canvas {
position: relative;
border: 1px solid var(--border-strong);
border-radius: 0.75rem;
overflow: hidden;
background-color: var(--bg-surface);
width: 100%;
}
/* Center canvas in stacked portrait mode (width/height set inline) */
.compare-diff-page__canvas[style*="margin-left: auto"] {
display: block;
}
.compare-diff-page__canvas--zoom {
overflow: hidden;
max-width: 100%;
}
.compare-diff-page__inner {
position: relative;
width: 100%;
margin-left: auto;
margin-right: auto;
max-width: 100%;
background-color: #fff; /* ensure stable white backing during load */
border: 1px solid var(--border-subtle);
will-change: transform;
}
.compare-diff-page__image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Centered per-page title wrapper */
.compare-page-title {
text-align: center;
margin-left: auto;
margin-right: auto;
}
.compare-page-title .mantine-Text-root {
display: inline-block;
padding: 2px 8px;
border-radius: 8px;
background-color: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
}
/* Overlay loader to avoid flash when image not yet loaded */
.compare-page-loader-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background-color: rgba(255, 255, 255, 0.9);
}
.compare-diff-highlight {
position: absolute;
pointer-events: none;
mix-blend-mode: normal;
}
/* Compare dropdown option formatting (page + clamped text) */
.compare-dropdown-option {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.compare-dropdown-option__page {
font-size: 0.7rem;
color: var(--text-muted);
}
.compare-dropdown-option__text {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-clamp: 3;
}
/* Non-sticky in-flow group headers; sticky handled by floating header */
.compare-dropdown-group {
position: static;
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border-subtle);
}
.compare-dropdown-group.compare-dropdown-group--hidden {
height: 0;
padding: 0;
margin: 0;
border: 0;
overflow: hidden;
}
[data-mantine-color-scheme="dark"] .compare-dropdown-group {
background: var(--compare-page-label-bg);
color: var(--compare-page-label-fg);
border-bottom: 1px solid var(--border-default);
}
/* Light grey rendering flag next to page labels in the dropdown */
.compare-dropdown-rendering-flag {
color: var(--text-muted);
margin-left: 0.25rem;
}
/* Inline paragraph highlights in summary */
.compare-inline {
border-radius: 0.2rem;
padding: 0.05rem 0.15rem;
}
.compare-inline--removed {
background-color: var(--spdf-compare-inline-removed-bg);
}
.compare-inline--added {
background-color: var(--spdf-compare-inline-added-bg);
}
.compare-pane-header {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-background);
padding: 0.25rem 0;
}
/* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */
.compare-tool__thumbnail {
width: 4rem;
height: 5.25rem;
flex-shrink: 0;
}
.compare-tool__details {
flex: 1;
min-width: 0;
}
/* Mobile: remove side margins and let canvases take full width inside column */
@media (max-width: 768px) {
.compare-workbench__columns {
grid-template-columns: 1fr;
}
.compare-diff-page__canvas.compare-diff-page__canvas--zoom {
width: 100% !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.compare-diff-page__inner {
margin-left: 0 !important;
margin-right: 0 !important;
}
}
@@ -0,0 +1,247 @@
import { RefObject, useCallback } from 'react';
type Pane = 'base' | 'comparison';
type SuppressOptions = {
temporarilySuppressScrollLink?: (fn: () => void, durationMs?: number) => void;
};
export const useCompareChangeNavigation = (
baseScrollRef: RefObject<HTMLDivElement | null>,
comparisonScrollRef: RefObject<HTMLDivElement | null>,
options?: SuppressOptions,
) => {
return useCallback(
(changeValue: string, pane: Pane, pageNumber?: number) => {
const suppress = <T extends void>(fn: () => T) => {
if (options?.temporarilySuppressScrollLink) {
options.temporarilySuppressScrollLink(fn, 700);
} else {
fn();
}
};
const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef;
const container = targetRef.current;
if (!container) {
return;
}
const findNodes = (): HTMLElement[] => {
return Array.from(
container.querySelectorAll(`[data-change-id="${changeValue}"]`)
) as HTMLElement[];
};
const scrollToPageIfNeeded = () => {
if (!pageNumber) return false;
const pageEl = container.querySelector(
`.compare-diff-page[data-page-number="${pageNumber}"]`
) as HTMLElement | null;
if (!pageEl) return false;
const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2);
suppress(() => {
container.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
});
return true;
};
const scrollPeerPageIfPossible = () => {
if (!pageNumber) return;
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
const peer = peerRef.current;
if (!peer) return;
const peerPageEl = peer.querySelector(
`.compare-diff-page[data-page-number="${pageNumber}"]`
) as HTMLElement | null;
if (!peerPageEl) return;
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)
)
);
suppress(() => {
peer.scrollTo({ top, behavior: 'auto' });
});
};
const proceedWithNodes = (nodes: HTMLElement[]) => {
if (nodes.length === 0) return;
// Prefer a percent-in-page based vertical scroll, which is resilient to transforms.
const anchor = nodes[0];
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
const inner = anchor.closest('.compare-diff-page__inner') as HTMLElement | null;
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
if (pageEl && inner && !Number.isNaN(topPercent)) {
const innerRect = inner.getBoundingClientRect();
const innerHeight = Math.max(1, innerRect.height);
const absoluteTopInPage = (topPercent / 100) * innerHeight;
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
const desiredTop = Math.max(
0,
Math.min(maxTop, pageEl.offsetTop + absoluteTopInPage - container.clientHeight / 2)
);
suppress(() => {
container.scrollTo({ top: desiredTop, behavior: 'auto' });
});
} else {
// Fallback to bounding-rect based centering if percent approach is unavailable.
const containerRect = container.getBoundingClientRect();
let minTop = Number.POSITIVE_INFINITY;
let minLeft = Number.POSITIVE_INFINITY;
let maxBottom = Number.NEGATIVE_INFINITY;
let maxRight = Number.NEGATIVE_INFINITY;
nodes.forEach((element) => {
const rect = element.getBoundingClientRect();
minTop = Math.min(minTop, rect.top);
minLeft = Math.min(minLeft, rect.left);
maxBottom = Math.max(maxBottom, rect.bottom);
maxRight = Math.max(maxRight, rect.right);
});
const boxHeight = Math.max(1, maxBottom - minTop);
const boxWidth = Math.max(1, maxRight - minLeft);
const absoluteTop = minTop - containerRect.top + container.scrollTop;
const absoluteLeft = minLeft - containerRect.left + container.scrollLeft;
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);
suppress(() => {
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'auto' });
});
}
// Also scroll the peer container to the corresponding location in the
// other PDF (same page and approximate vertical position within page),
// not just the same list/scroll position.
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
const peer = peerRef.current;
if (peer) {
// Use the first node as the anchor
const anchor = nodes[0];
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
const pageNumAttr = pageEl?.getAttribute('data-page-number');
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
if (pageNumAttr) {
const peerPageEl = peer.querySelector(
`.compare-diff-page[data-page-number="${pageNumAttr}"]`
) as HTMLElement | null;
const peerInner = peerPageEl?.querySelector('.compare-diff-page__inner') as HTMLElement | null;
if (peerPageEl && peerInner) {
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,
Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2)
);
suppress(() => {
peer.scrollTo({ top: peerDesiredTop, behavior: 'auto' });
});
} else if (peerPageEl) {
// 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)));
suppress(() => {
peer.scrollTo({ top, behavior: 'auto' });
});
}
}
}
const groupsByInner = new Map<HTMLElement, HTMLElement[]>();
nodes.forEach((element) => {
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;
if (!inner) return;
const list = groupsByInner.get(inner) ?? [];
list.push(element);
groupsByInner.set(inner, list);
});
groupsByInner.forEach((elements, inner) => {
let minL = 100;
let minT = 100;
let maxR = 0;
let maxB = 0;
elements.forEach((element) => {
const leftPercent = parseFloat(element.style.left) || 0;
const topPercent = parseFloat(element.style.top) || 0;
const widthPercent = parseFloat(element.style.width) || 0;
const heightPercent = parseFloat(element.style.height) || 0;
minL = Math.min(minL, leftPercent);
minT = Math.min(minT, topPercent);
maxR = Math.max(maxR, leftPercent + widthPercent);
maxB = Math.max(maxB, topPercent + heightPercent);
});
const overlay = document.createElement('span');
overlay.className = 'compare-diff-flash-overlay';
overlay.style.position = 'absolute';
overlay.style.left = `${minL}%`;
overlay.style.top = `${minT}%`;
overlay.style.width = `${Math.max(0.1, maxR - minL)}%`;
overlay.style.height = `${Math.max(0.1, maxB - minT)}%`;
inner.appendChild(overlay);
window.setTimeout(() => overlay.remove(), 1600);
});
nodes.forEach((element) => {
element.classList.remove('compare-diff-highlight--flash');
});
void container.clientWidth; // Force reflow
nodes.forEach((element) => {
element.classList.add('compare-diff-highlight--flash');
window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600);
});
};
const nodes = findNodes();
if (nodes.length > 0) {
proceedWithNodes(nodes);
return;
}
// Page-level fallback immediately so the user sees something happen
const scrolledPage = scrollToPageIfNeeded();
if (scrolledPage) {
scrollPeerPageIfPossible();
} else {
// Even if the page element is not present yet, try to nudge peer pane
scrollPeerPageIfPossible();
}
// Wait for highlights to mount (pages/images render progressively)
let settled = false;
const observer = new MutationObserver(() => {
if (settled) return;
const n = findNodes();
if (n.length > 0) {
settled = true;
observer.disconnect();
proceedWithNodes(n);
}
});
try {
observer.observe(container, { childList: true, subtree: true });
} catch {
// noop
}
// Safety timeout to stop waiting after a while
window.setTimeout(() => {
if (settled) return;
settled = true;
observer.disconnect();
// We already scrolled to the page above; nothing else to do.
}, 5000);
},
[baseScrollRef, comparisonScrollRef]
);
};
export type UseCompareChangeNavigationReturn = ReturnType<typeof useCompareChangeNavigation>;
@@ -0,0 +1,144 @@
import { useCallback, useMemo } from 'react';
import type {
CompareFilteredTokenInfo,
WordHighlightEntry,
CompareResultData,
CompareChangeOption,
PagePreview,
} from '@app/types/compare';
interface MetaGroupMap {
base: Map<number, string>;
comparison: Map<number, string>;
}
interface WordHighlightMaps {
base: Map<number, WordHighlightEntry[]>;
comparison: Map<number, WordHighlightEntry[]>;
}
export interface UseCompareHighlightsResult {
baseWordChanges: CompareChangeOption[];
comparisonWordChanges: CompareChangeOption[];
metaIndexToGroupId: MetaGroupMap;
wordHighlightMaps: WordHighlightMaps;
getRowHeightPx: (pageNumber: number) => number;
}
const buildWordChanges = (
tokens: CompareFilteredTokenInfo[],
metaIndexToGroupId: Map<number, string>,
groupPrefix: string
): CompareChangeOption[] => {
metaIndexToGroupId.clear();
if (!tokens.length) return [];
const items: CompareChangeOption[] = [];
let currentRun: CompareFilteredTokenInfo[] = [];
const flushRun = () => {
if (currentRun.length === 0) return;
const label = currentRun.map((token) => token.token).join(' ').trim();
if (label.length === 0) {
currentRun = [];
return;
}
const first = currentRun[0];
const last = currentRun[currentRun.length - 1];
const groupId = `${groupPrefix}-t${first.metaIndex}-t${last.metaIndex}`;
currentRun.forEach((token) => {
metaIndexToGroupId.set(token.metaIndex, groupId);
});
const pageNumber = first.page ?? last.page ?? 1;
items.push({ value: groupId, label, pageNumber });
currentRun = [];
};
for (const token of tokens) {
if (token.hasHighlight && token.bbox) {
currentRun.push(token);
} else {
flushRun();
}
}
flushRun();
return items;
};
const buildHighlightMap = (
tokens: CompareFilteredTokenInfo[]
): Map<number, WordHighlightEntry[]> => {
const map = new Map<number, WordHighlightEntry[]>();
for (const token of tokens) {
if (!token.hasHighlight || !token.bbox || token.page == null) continue;
const list = map.get(token.page) ?? [];
list.push({ rect: token.bbox, metaIndex: token.metaIndex });
map.set(token.page, list);
}
return map;
};
export const useCompareHighlights = (
result: CompareResultData | null,
basePages: PagePreview[],
comparisonPages: PagePreview[],
): UseCompareHighlightsResult => {
const baseMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
const comparisonMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
const baseWordChanges = useMemo(() => {
if (!result) return [];
return buildWordChanges(
result.filteredTokenData.base,
baseMetaIndexToGroupId,
'base-group'
);
}, [baseMetaIndexToGroupId, result]);
const comparisonWordChanges = useMemo(() => {
if (!result) return [];
return buildWordChanges(
result.filteredTokenData.comparison,
comparisonMetaIndexToGroupId,
'comparison-group'
);
}, [comparisonMetaIndexToGroupId, result]);
const wordHighlightMaps = useMemo(() => {
if (!result) {
return {
base: new Map<number, WordHighlightEntry[]>(),
comparison: new Map<number, WordHighlightEntry[]>(),
};
}
return {
base: buildHighlightMap(result.filteredTokenData.base),
comparison: buildHighlightMap(result.filteredTokenData.comparison),
};
}, [result]);
const getRowHeightPx = useCallback(
(pageNumber: number) => {
const basePage = basePages.find((page) => page.pageNumber === pageNumber);
const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber);
const baseHeight = basePage ? basePage.height : 0;
const comparisonHeight = comparisonPage ? comparisonPage.height : 0;
const rowHeight = Math.max(baseHeight, comparisonHeight);
return Math.round(rowHeight);
},
[basePages, comparisonPages]
);
return {
baseWordChanges,
comparisonWordChanges,
metaIndexToGroupId: {
base: baseMetaIndexToGroupId,
comparison: comparisonMetaIndexToGroupId,
},
wordHighlightMaps,
getRowHeightPx,
};
};
@@ -0,0 +1,253 @@
import { useEffect, useRef, useState } from 'react';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import type { PagePreview } from '@app/types/compare';
const DISPLAY_SCALE = 1;
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
// Observable preview cache so rendering progress can resume across remounts and view switches
type CacheEntry = { pages: PagePreview[]; total: number; subscribers: Set<() => void> };
const previewCache: Map<string, CacheEntry> = new Map();
const latestVersionMap: Map<string, symbol> = new Map();
const getOrCreateEntry = (key: string): CacheEntry => {
let entry = previewCache.get(key);
if (!entry) {
entry = { pages: [], total: 0, subscribers: new Set() };
previewCache.set(key, entry);
}
return entry;
};
const notify = (entry: CacheEntry) => {
entry.subscribers.forEach((fn) => {
try { fn(); } catch { /* no-op */ }
});
};
const subscribe = (key: string, fn: () => void): (() => void) => {
const entry = getOrCreateEntry(key);
entry.subscribers.add(fn);
return () => entry.subscribers.delete(fn);
};
const appendBatchToCache = (key: string, batch: PagePreview[], provisionalTotal?: number) => {
const entry = getOrCreateEntry(key);
const next = entry.pages.slice();
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);
}
entry.pages = next;
if (typeof provisionalTotal === 'number' && entry.total === 0) entry.total = provisionalTotal;
notify(entry);
};
const setTotalInCache = (key: string, total: number) => {
const entry = getOrCreateEntry(key);
entry.total = total;
notify(entry);
};
const replacePagesInCache = (key: string, pages: PagePreview[], total?: number) => {
const entry = getOrCreateEntry(key);
entry.pages = pages.slice();
if (typeof total === 'number') entry.total = total;
notify(entry);
};
const renderPdfDocumentToImages = async (
file: File,
onBatch?: (previews: PagePreview[]) => void,
batchSize: number = 12,
onInitTotal?: (totalPages: number) => void,
startAtPage: number = 1,
shouldAbort?: () => boolean,
): Promise<PagePreview[]> => {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const previews: PagePreview[] = [];
const dpr = getDevicePixelRatio();
const renderScale = Math.max(2, Math.min(3, dpr * 2));
onInitTotal?.(pdf.numPages);
let batch: PagePreview[] = [];
const shouldStop = () => Boolean(shouldAbort?.());
for (let pageNumber = Math.max(1, startAtPage); pageNumber <= pdf.numPages; pageNumber += 1) {
if (shouldStop()) break;
const page = await pdf.getPage(pageNumber);
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
const renderViewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = Math.round(renderViewport.width);
canvas.height = Math.round(renderViewport.height);
if (!context) {
page.cleanup();
continue;
}
try {
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
if (shouldStop()) break;
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 = [];
}
}
} finally {
page.cleanup();
canvas.width = 0;
canvas.height = 0;
}
if (shouldStop()) break;
}
if (onBatch && batch.length > 0) onBatch(batch);
return previews;
} finally {
pdfWorkerManager.destroyDocument(pdf);
}
};
interface UseComparePagePreviewsOptions {
file: File | null;
enabled: boolean;
cacheKey: number | null;
}
export const useComparePagePreviews = ({
file,
enabled,
cacheKey,
}: UseComparePagePreviewsOptions) => {
const [pages, setPages] = useState<PagePreview[]>([]);
const [loading, setLoading] = useState(false);
const [totalPages, setTotalPages] = useState(0);
const inFlightRef = useRef(0);
useEffect(() => {
let cancelled = false;
if (!file || !enabled) {
setPages([]);
setLoading(false);
setTotalPages(0);
return () => {
cancelled = true;
};
}
const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`;
const refreshVersion = Symbol(key);
latestVersionMap.set(key, refreshVersion);
const entry = getOrCreateEntry(key);
const cachedTotal = entry.total ?? (entry.pages.length ?? 0);
let lastKnownTotal = cachedTotal;
const isFullyCached = Boolean(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal);
if (entry.pages.length > 0) {
const nextPages = entry.pages.slice();
setPages(nextPages);
setTotalPages(cachedTotal);
} else {
setTotalPages(0);
}
setLoading(!isFullyCached);
const unsubscribe = subscribe(key, () => {
const e = getOrCreateEntry(key);
setPages(e.pages.slice());
setTotalPages(e.total);
const done = e.pages.length > 0 && e.total > 0 && e.pages.length >= e.total;
setLoading(!done);
});
if (isFullyCached) {
return () => {
cancelled = true;
};
}
const render = async () => {
setLoading(true);
try {
inFlightRef.current += 1;
const current = inFlightRef.current;
const startAt = (entry?.pages?.length ?? 0) + 1;
const previews = await renderPdfDocumentToImages(
file,
(batch) => {
if (cancelled || current !== inFlightRef.current) return;
appendBatchToCache(key, batch, lastKnownTotal || cachedTotal);
},
16,
(total) => {
if (!cancelled && current === inFlightRef.current) {
lastKnownTotal = total;
setTotalInCache(key, total);
}
},
startAt,
() => cancelled || current !== inFlightRef.current
);
if (!cancelled && current === inFlightRef.current) {
const stillLatest = latestVersionMap.get(key) === refreshVersion;
if (!stillLatest) {
return;
}
const cacheEntry = getOrCreateEntry(key);
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length;
lastKnownTotal = finalTotal;
const cachePages = cacheEntry.pages ?? [];
const preferPreviews = previews.length > cachePages.length;
const finalPages = preferPreviews ? previews.slice() : cachePages.slice();
replacePagesInCache(key, finalPages, finalTotal);
}
} catch (error) {
console.error('[compare] failed to render document preview', error);
if (!cancelled) {
setPages([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
render();
return () => {
cancelled = true;
unsubscribe();
};
}, [file, enabled, cacheKey]);
return { pages, loading, totalPages, renderedPages: pages.length };
};
export type UseComparePagePreviewsReturn = ReturnType<typeof useComparePagePreviews>;
@@ -0,0 +1,895 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type {
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
WheelEvent as ReactWheelEvent,
} from 'react';
import type {
PagePreview,
ComparePane as Pane,
PanState,
ScrollLinkDelta,
ScrollLinkAnchors,
PanDragState,
PinchState,
UseComparePanZoomOptions,
UseComparePanZoomReturn,
} from '@app/types/compare';
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;
// (Interfaces moved to @app/types/compare)
export const useComparePanZoom = ({
basePages,
comparisonPages,
prefersStacked,
}: UseComparePanZoomOptions): UseComparePanZoomReturn => {
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,
deltaPixelsCompToBase: 0,
});
const [isScrollLinked, setIsScrollLinked] = useState(true);
const [isPanMode, setIsPanMode] = useState(false);
const panDragRef = useRef<PanDragState>({
active: false,
source: null,
startX: 0,
startY: 0,
startPanX: 0,
startPanY: 0,
targetStartPanX: 0,
targetStartPanY: 0,
});
const lastActivePaneRef = useRef<Pane>('base');
const [baseZoom, setBaseZoom] = useState(1);
const [comparisonZoom, setComparisonZoom] = useState(1);
const [basePan, setBasePan] = useState<PanState>({ x: 0, y: 0 });
const [comparisonPan, setComparisonPan] = useState<PanState>({ x: 0, y: 0 });
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') => {
setLayoutState(next);
}, []);
const toggleLayout = useCallback(() => {
setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side'));
}, []);
useEffect(() => {
setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev));
}, [prefersStacked]);
const getPagesForPane = useCallback(
(pane: Pane) => (pane === 'base' ? basePages : comparisonPages),
[basePages, comparisonPages]
);
// rAF-coalesced follower scroll writes
const syncRafRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
const desiredTopRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
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),
...comparisonPages.map(p => p.pageNumber),
])
).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 (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[]) => {
const out: number[] = new Array(arr.length + 1);
out[0] = 0;
for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i];
return out;
};
return {
base,
comp,
basePrefix: prefix(base),
compPrefix: prefix(comp),
};
}, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]);
const mapScrollTopBetweenPanes = useCallback(
(sourceTop: number, sourceIsBase: boolean): number => {
const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp;
const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base;
const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix;
const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix;
if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop;
// Clamp to valid range
const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1);
const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop)));
// Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1]
let lo = 0;
let hi = srcHeights.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2);
if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1;
}
const i = lo;
const within = top - srcPrefix[i];
const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0;
const j = Math.min(i, dstHeights.length - 1);
const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1);
return dstTop;
},
[rowHeights]
);
const getMaxCanvasSize = useCallback(
(pane: Pane) => {
const pages = getPagesForPane(pane);
const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base');
let maxW = 0;
let maxH = 0;
for (const page of pages) {
const peer = peers.find(p => p.pageNumber === page.pageNumber);
const targetHeight = peer ? Math.max(page.height, peer.height) : page.height;
const fit = targetHeight / page.height;
const width = Math.round(page.width * fit);
const height = Math.round(targetHeight);
if (width > maxW) maxW = width;
if (height > maxH) maxH = height;
}
return { maxW, maxH };
},
[getPagesForPane]
);
const getPanBounds = useCallback(
(pane: Pane, zoomOverride?: number) => {
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null;
let canvasW: number | null = null;
let canvasH: number | null = null;
if (canvasEl) {
const rect = canvasEl.getBoundingClientRect();
canvasW = Math.max(0, Math.round(rect.width));
canvasH = Math.max(0, Math.round(rect.height));
}
const fallback = getMaxCanvasSize(pane);
const W = canvasW ?? fallback.maxW;
const H = canvasH ?? fallback.maxH;
const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom;
const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1));
const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1));
return { maxX: extraX, maxY: extraY };
},
[baseZoom, comparisonZoom, getMaxCanvasSize]
);
const getPaneRotation = useCallback(
(pane: Pane) => {
const pages = getPagesForPane(pane);
const rotation = pages[0]?.rotation ?? 0;
const normalized = ((rotation % 360) + 360) % 360;
return normalized as 0 | 90 | 180 | 270 | number;
},
[getPagesForPane]
);
const mapPanBetweenOrientations = useCallback(
(source: Pane, target: Pane, sourcePan: PanState) => {
const sourceRotation = getPaneRotation(source);
const targetRotation = getPaneRotation(target);
const sourceBounds = getPanBounds(source);
const targetBounds = getPanBounds(target);
const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1;
const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1;
const applyRotation = (nx: number, ny: number, rotation: number) => {
const r = ((rotation % 360) + 360) % 360;
if (r === 0) return { nx, ny };
if (r === 90) return { nx: ny, ny: -nx };
if (r === 180) return { nx: -nx, ny: -ny };
if (r === 270) return { nx: -ny, ny: nx };
return { nx, ny };
};
const logical = applyRotation(sx, sy, sourceRotation);
const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation);
const targetNormX = (targetCentered.nx + 1) / 2;
const targetNormY = (targetCentered.ny + 1) / 2;
const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX));
const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY));
return { x: targetX, y: targetY };
},
[getPaneRotation, getPanBounds]
);
const centerPanForZoom = useCallback(
(pane: Pane, zoomValue: number) => {
const bounds = getPanBounds(pane, zoomValue);
const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) };
if (pane === 'base') {
setBasePan(center);
} else {
setComparisonPan(center);
}
},
[getPanBounds]
);
const setPanToTopLeft = useCallback((pane: Pane) => {
if (pane === 'base') {
setBasePan({ x: 0, y: 0 });
} else {
setComparisonPan({ x: 0, y: 0 });
}
}, []);
const clampPanForZoom = useCallback(
(pane: Pane, zoomValue: number) => {
const bounds = getPanBounds(pane, zoomValue);
const current = pane === 'base' ? basePan : comparisonPan;
const clamped = {
x: Math.max(0, Math.min(bounds.maxX, current.x)),
y: Math.max(0, Math.min(bounds.maxY, current.y)),
};
if (pane === 'base') {
setBasePan(clamped);
} else {
setComparisonPan(clamped);
}
},
[basePan, comparisonPan, getPanBounds]
);
const handleScrollSync = useCallback(
(source: HTMLDivElement | null, target: HTMLDivElement | null) => {
if (panDragRef.current.active) return;
if (!source || !target || isSyncingRef.current || !isScrollLinked) {
return;
}
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);
// Use pixel anchors captured at link time to preserve offset
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
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;
}
const targetIsBase = target === baseScrollRef.current;
const key = targetIsBase ? 'base' : 'comparison';
desiredTopRef.current[key] = desiredTop;
if (syncRafRef.current[key] == null) {
syncRafRef.current[key] = requestAnimationFrame(() => {
const el = targetIsBase ? baseScrollRef.current : comparisonScrollRef.current;
const top = desiredTopRef.current[key] ?? 0;
if (el) {
isSyncingRef.current = true;
el.scrollTop = top;
}
syncRafRef.current[key] = null;
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
});
}
},
[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 = () => {
// Ignore programmatic scrolls to avoid feedback loops and unnecessary syncing work
if (isSyncingRef.current) return;
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(); };
}, []);
// Helpers for clearer pan-edge overscroll behavior
const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => {
if (rawY < 0) return rawY; // negative -> scroll up
if (rawY > maxY) return rawY - maxY; // positive -> scroll down
return 0;
}, []);
const normalizeApplyCandidate = useCallback((overflowY: number): number => {
const DEADZONE = 32; // pixels
if (overflowY < -DEADZONE) return overflowY + DEADZONE;
if (overflowY > DEADZONE) return overflowY - DEADZONE;
return 0;
}, []);
const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => {
const STEP = 48; // pixels per incremental scroll
const key = isBase ? 'base' : 'comparison';
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
const magnitude = Math.abs(deltaSinceLast);
if (magnitude < STEP) return;
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) return;
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;
}
}
}, [isScrollLinked, mapScrollTopBetweenPanes]);
const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => {
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
if (!container) return;
const overflowY = getVerticalOverflow(rawY, boundsMaxY);
const applyCandidate = normalizeApplyCandidate(overflowY);
if (applyCandidate !== 0) {
applyIncrementalScroll(container, isBase, applyCandidate);
} else {
// Reset accumulator when back within deadzone
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
}
}, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]);
const beginPan = useCallback(
(pane: Pane, event: ReactMouseEvent<HTMLDivElement>) => {
if (!isPanMode) return;
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
if (zoom <= 1) return;
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
if (!container) return;
const targetEl = event.target as HTMLElement | null;
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
if (!isOnImage) return;
event.preventDefault();
panDragRef.current = {
active: true,
source: pane,
startX: event.clientX,
startY: event.clientY,
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
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';
},
[isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan]
);
const continuePan = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!isPanMode) return;
const drag = panDragRef.current;
if (!drag.active || !drag.source) return;
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
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, rawX)),
y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
// On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
if (isBase) {
setBasePan(desired);
} else {
setComparisonPan(desired);
}
},
[getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations]
);
const endPan = useCallback(() => {
const drag = panDragRef.current;
if (!drag.active) return;
const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
if (sourceEl) {
const zoom = drag.source === 'base' ? baseZoom : comparisonZoom;
(sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : '';
}
panDragRef.current.active = false;
panDragRef.current.source = null;
}, [baseZoom, comparisonZoom, isPanMode]);
const handleWheelZoom = useCallback(
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey) return;
event.preventDefault();
const key = pane === 'base' ? 'base' : 'comparison';
const accum = wheelZoomAccumRef.current;
const threshold = 180;
accum[key] += event.deltaY;
const steps = Math.trunc(Math.abs(accum[key]) / threshold);
if (steps <= 0) return;
const direction = accum[key] > 0 ? -1 : 1;
accum[key] = accum[key] % threshold;
const applySteps = (zoom: number) => {
let next = zoom;
for (let i = 0; i < steps; i += 1) {
next = direction > 0
? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2))
: Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2));
}
return next;
};
if (pane === 'base') {
const prev = baseZoom;
const next = applySteps(prev);
setBaseZoom(next);
if (next < prev) {
centerPanForZoom('base', next);
} else {
clampPanForZoom('base', next);
}
} else {
const prev = comparisonZoom;
const next = applySteps(prev);
setComparisonZoom(next);
if (next < prev) {
centerPanForZoom('comparison', next);
} else {
clampPanForZoom('comparison', next);
}
}
},
[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) {
const [t1, t2] = [event.touches[0], event.touches[1]];
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
pinchRef.current = {
active: true,
pane,
startDistance: Math.hypot(dx, dy),
startZoom: pane === 'base' ? baseZoom : comparisonZoom,
};
event.preventDefault();
} else if (event.touches.length === 1) {
if (!isPanMode) return;
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
if (zoom <= 1) return;
const targetEl = event.target as HTMLElement | null;
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
if (!isOnImage) return;
const touch = event.touches[0];
panDragRef.current = {
active: true,
source: pane,
startX: touch.clientX,
startY: touch.clientY,
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
};
edgeOverscrollRef.current[pane] = 0;
event.preventDefault();
}
},
[basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode]
);
const onTouchMove = useCallback(
(event: ReactTouchEvent<HTMLDivElement>) => {
if (pinchRef.current.active && event.touches.length === 2) {
const [t1, t2] = [event.touches[0], event.touches[1]];
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
const distance = Math.hypot(dx, dy);
const scale = distance / Math.max(1, pinchRef.current.startDistance);
const dampened = 1 + (scale - 1) * 0.6;
const pane = pinchRef.current.pane!;
const startZoom = pinchRef.current.startZoom;
const previousZoom = pane === 'base' ? baseZoom : comparisonZoom;
const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
if (pane === 'base') {
setBaseZoom(nextZoom);
if (nextZoom < previousZoom) {
centerPanForZoom('base', nextZoom);
} else if (nextZoom > previousZoom) {
clampPanForZoom('base', nextZoom);
}
} else {
setComparisonZoom(nextZoom);
if (nextZoom < previousZoom) {
centerPanForZoom('comparison', nextZoom);
} else if (nextZoom > previousZoom) {
clampPanForZoom('comparison', nextZoom);
}
}
event.preventDefault();
return;
}
if (panDragRef.current.active && event.touches.length === 1) {
const touch = event.touches[0];
const dx = touch.clientX - panDragRef.current.startX;
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, rawX)),
y: Math.max(0, Math.min(bounds.maxY, rawY)),
};
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
if (isBase) {
setBasePan(desired);
} else {
setComparisonPan(desired);
}
event.preventDefault();
}
},
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations]
);
const onTouchEnd = useCallback(() => {
pinchRef.current.active = false;
pinchRef.current.pane = null;
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]);
// 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;
const target = event.target as HTMLElement | null;
const tag = (target?.tagName || '').toLowerCase();
const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true');
if (isEditable) return;
const baseEl = baseScrollRef.current;
const compEl = comparisonScrollRef.current;
if (!baseEl || !compEl) return;
const STEP = 80;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const delta = event.key === 'ArrowDown' ? STEP : -STEP;
isSyncingRef.current = true;
baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight));
compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight));
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isScrollLinked]);
const captureScrollLinkDelta = useCallback(() => {
const baseEl = baseScrollRef.current;
const compEl = comparisonScrollRef.current;
if (!baseEl || !compEl) {
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
return;
}
const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight);
const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight);
const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth);
const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth);
const baseV = baseEl.scrollTop / baseVMax;
const compV = compEl.scrollTop / compVMax;
const baseH = baseEl.scrollLeft / baseHMax;
const compH = compEl.scrollLeft / compHMax;
scrollLinkDeltaRef.current = {
vertical: compV - baseV,
horizontal: compH - baseH,
};
// Capture pixel anchors in mapped space
const mappedBaseToComp = mapScrollTopBetweenPanes(baseEl.scrollTop, true);
const mappedCompToBase = mapScrollTopBetweenPanes(compEl.scrollTop, false);
scrollLinkAnchorsRef.current = {
deltaPixelsBaseToComp: compEl.scrollTop - mappedBaseToComp,
deltaPixelsCompToBase: baseEl.scrollTop - mappedCompToBase,
};
}, [mapScrollTopBetweenPanes]);
const clearScrollLinkDelta = useCallback(() => {
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
}, []);
const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []);
return {
layout,
setLayout,
toggleLayout,
baseScrollRef,
comparisonScrollRef,
isScrollLinked,
setIsScrollLinked,
captureScrollLinkDelta,
clearScrollLinkDelta,
isPanMode,
setIsPanMode,
baseZoom,
setBaseZoom,
comparisonZoom,
setComparisonZoom,
basePan,
comparisonPan,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
handleScrollSync,
beginPan,
continuePan,
endPan,
handleWheelZoom,
handleWheelOverscroll,
onTouchStart,
onTouchMove,
onTouchEnd,
zoomLimits,
};
};
@@ -0,0 +1,191 @@
import { useMemo } from 'react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import type { ToastLocation } from '@app/components/toast/types';
import type { RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
import { useIsMobile } from '@app/hooks/useIsMobile';
type Pane = 'base' | 'comparison';
export interface UseCompareRightRailButtonsOptions {
layout: 'side-by-side' | 'stacked';
toggleLayout: () => void;
isPanMode: boolean;
setIsPanMode: (value: boolean) => void;
baseZoom: number;
comparisonZoom: number;
setBaseZoom: (value: number) => void;
setComparisonZoom: (value: number) => void;
setPanToTopLeft: (pane: Pane) => void;
centerPanForZoom: (pane: Pane, zoom: number) => void;
clampPanForZoom: (pane: Pane, zoom: number) => void;
clearScrollLinkDelta: () => void;
captureScrollLinkDelta: () => void;
isScrollLinked: boolean;
setIsScrollLinked: (value: boolean) => void;
zoomLimits: { min: number; max: number; step: number };
baseScrollRef?: React.RefObject<HTMLDivElement | null>;
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
}
export const useCompareRightRailButtons = ({
layout,
toggleLayout,
isPanMode,
setIsPanMode,
baseZoom,
comparisonZoom,
setBaseZoom,
setComparisonZoom,
setPanToTopLeft,
centerPanForZoom,
clampPanForZoom,
clearScrollLinkDelta,
captureScrollLinkDelta,
isScrollLinked,
setIsScrollLinked,
zoomLimits,
baseScrollRef,
comparisonScrollRef,
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
const { t } = useTranslation();
const isMobile = useIsMobile();
return useMemo<RightRailButtonWithAction[]>(() => [
{
id: 'compare-toggle-layout',
icon: (
<LocalIcon
icon={layout === 'side-by-side' ? 'vertical-split-rounded' : 'horizontal-split-rounded'}
width="1.5rem"
height="1.5rem"
/>
),
tooltip: layout === 'side-by-side'
? t('compare.actions.stackVertically', 'Stack vertically')
: t('compare.actions.placeSideBySide', 'Place side by side'),
ariaLabel: layout === 'side-by-side'
? t('compare.actions.stackVertically', 'Stack vertically')
: t('compare.actions.placeSideBySide', 'Place side by side'),
section: 'top',
order: 10,
onClick: toggleLayout,
},
{
id: 'compare-zoom-out',
icon: <LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />,
tooltip: t('compare.actions.zoomOut', 'Zoom out'),
ariaLabel: t('compare.actions.zoomOut', 'Zoom out'),
section: 'top',
order: 13,
onClick: () => {
const { min, step } = zoomLimits;
const nextBase = Math.max(min, +(baseZoom - step).toFixed(2));
const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2));
setBaseZoom(nextBase);
setComparisonZoom(nextComparison);
centerPanForZoom('base', nextBase);
centerPanForZoom('comparison', nextComparison);
},
},
{
id: 'compare-zoom-in',
icon: <LocalIcon icon="zoom-in" width="1.5rem" height="1.5rem" />,
tooltip: t('compare.actions.zoomIn', 'Zoom in'),
ariaLabel: t('compare.actions.zoomIn', 'Zoom in'),
section: 'top',
order: 14,
onClick: () => {
const { max, step } = zoomLimits;
const nextBase = Math.min(max, +(baseZoom + step).toFixed(2));
const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2));
setBaseZoom(nextBase);
setComparisonZoom(nextComparison);
clampPanForZoom('base', nextBase);
clampPanForZoom('comparison', nextComparison);
},
},
{
id: 'compare-reset-view',
icon: <LocalIcon icon="refresh-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t('compare.actions.resetView', 'Reset zoom and pan'),
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
section: 'top',
order: 14.5,
disabled: baseZoom === 1 && comparisonZoom === 1,
onClick: () => {
setBaseZoom(1);
setComparisonZoom(1);
setPanToTopLeft('base');
setPanToTopLeft('comparison');
clearScrollLinkDelta();
// Reset scrollTop for both panes to realign view
if (baseScrollRef?.current) {
baseScrollRef.current.scrollTop = 0;
}
if (comparisonScrollRef?.current) {
comparisonScrollRef.current.scrollTop = 0;
}
},
},
{
id: 'compare-toggle-scroll-link',
icon: (
<LocalIcon
icon={isScrollLinked ? 'link-rounded' : 'link-off-rounded'}
width="1.5rem"
height="1.5rem"
/>
),
tooltip: isScrollLinked
? t('compare.actions.unlinkScroll', 'Unlink scroll')
: t('compare.actions.linkScroll', 'Link scroll'),
ariaLabel: isScrollLinked
? t('compare.actions.unlinkScroll', 'Unlink scroll')
: t('compare.actions.linkScroll', 'Link scroll'),
section: 'top',
order: 15,
onClick: () => {
const next = !isScrollLinked;
if (next) {
captureScrollLinkDelta();
} else {
if (!isMobile) {
alert({
alertType: 'neutral',
title: t('compare.toasts.unlinkedTitle', 'Independent scroll enabled'),
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes when unlinked is off.'),
durationMs: 5000,
location: 'bottom-center' as ToastLocation,
expandable: false,
});
}
}
setIsScrollLinked(next);
},
},
], [
layout,
toggleLayout,
isPanMode,
setIsPanMode,
baseZoom,
comparisonZoom,
setBaseZoom,
setComparisonZoom,
centerPanForZoom,
clampPanForZoom,
setPanToTopLeft,
clearScrollLinkDelta,
captureScrollLinkDelta,
isScrollLinked,
setIsScrollLinked,
zoomLimits,
t,
isMobile,
]);
};
export type UseCompareRightRailButtonsReturn = ReturnType<typeof useCompareRightRailButtons>;
@@ -87,7 +87,7 @@ const FileStatusIndicator = ({
<Text size="sm" c="dimmed">
<Anchor
size="sm"
onClick={() => openFilesModal()}
onClick={() => openFilesModal({})}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
@@ -122,7 +122,7 @@ const FileStatusIndicator = ({
{getPlaceholder() + " "}
<Anchor
size="sm"
onClick={() => openFilesModal()}
onClick={() => openFilesModal({})}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
@@ -86,7 +86,6 @@ export function createToolFlow(config: ToolFlowConfig) {
{config.steps.map((stepConfig) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,
onCollapsedClick: stepConfig.onCollapsedClick,
tooltip: stepConfig.tooltip
}, stepConfig.content)