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
@@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
@@ -64,7 +64,7 @@ const FileEditorThumbnail = ({
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const isMobile = useIsMobile();
const [showCloseModal, setShowCloseModal] = useState(false);
// Resolve the actual File object for pin/unpin operations
@@ -81,6 +81,7 @@ export default function Workbench() {
switch (currentView) {
case "fileEditor":
return (
<FileEditor
toolMode={!!selectedToolId}
@@ -98,6 +99,7 @@ export default function Workbench() {
);
case "viewer":
return (
<Viewer
sidebarsVisible={sidebarsVisible}
@@ -110,6 +112,7 @@ export default function Workbench() {
);
case "pageEditor":
return (
<>
<PageEditor
@@ -143,6 +146,8 @@ export default function Workbench() {
default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
@@ -154,7 +159,7 @@ export default function Workbench() {
return (
<Box
className="flex-1 h-full min-w-80 relative flex flex-col"
className="flex-1 h-full min-w-0 relative flex flex-col"
data-tour="workbench"
style={
isRainbowMode
@@ -182,10 +187,11 @@ export default function Workbench() {
{/* Main content area */}
<Box
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
className={`flex-1 min-h-0 relative z-10 ${currentView === 'viewer' || !isBaseWorkbench(currentView) ? '' : styles.workbenchScrollable}`}
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined,
}}
>
{renderMainContent()}
@@ -1,6 +1,6 @@
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Text, Checkbox } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useIsMobile } from '@app/hooks/useIsMobile';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
@@ -69,7 +69,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const isMobile = useIsMobile();
const dragElementRef = useRef<HTMLDivElement>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
@@ -1,12 +1,12 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AppConfigModalProps {
@@ -15,10 +15,10 @@ interface AppConfigModalProps {
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('general');
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
const [active, setActive] = useState<NavKey>('general');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config } = useAppConfig();
// Extract section from URL path (e.g., /settings/people -> people)
@@ -65,6 +65,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
const clickPendingRef = useRef(false);
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
// Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target
const isDomNode = (value: unknown): value is Node =>
typeof Node !== 'undefined' && value instanceof Node;
const clearTimers = useCallback(() => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
@@ -103,9 +107,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: MouseEvent) => {
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as Node | null;
const insideTooltip = tEl && target && tEl.contains(target);
const insideTrigger = trg && target && trg.contains(target);
const target = e.target as unknown;
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
@@ -172,7 +176,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
@@ -236,7 +240,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
return;
}
@@ -258,7 +262,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
},
[isPinned, setOpen]
@@ -168,7 +168,8 @@ const TopControls = ({
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
<SegmentedControl
data-tour="view-switcher"
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
@@ -93,13 +93,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
progress,
} as ToastInstance;
// Detect completion
// Detect completion but do not auto-flip to success.
// Callers (e.g., compare workbench) explicitly set alertType when done.
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
// On completion: finalize type as success unless explicitly provided otherwise
next.justCompleted = false;
if (!updates.alertType) {
next.alertType = 'success';
}
next.justCompleted = true;
}
return next;
@@ -31,6 +31,13 @@
flex-direction: column-reverse;
}
.toast-container--bottom-center {
bottom: 16px;
left: 50%;
transform: translateX(-50%);
flex-direction: column-reverse;
}
/* Toast Item Styles */
.toast-item {
min-width: 320px;
@@ -8,6 +8,7 @@ const locationToClass: Record<ToastLocation, string> = {
'top-right': 'toast-container--top-right',
'bottom-left': 'toast-container--bottom-left',
'bottom-right': 'toast-container--bottom-right',
'bottom-center': 'toast-container--bottom-center',
};
function getToastItemClass(t: ToastInstance): string {
@@ -44,7 +45,7 @@ export default function ToastRenderer() {
if (!acc[key]) acc[key] = [] as ToastInstance[];
acc[key].push(t);
return acc;
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] });
return (
<>
+17 -10
View File
@@ -1,4 +1,4 @@
import { ToastOptions } from '@app/components/toast/types';
import { ToastApi, ToastInstance, ToastOptions } from '@app/components/toast/types';
import { useToast, ToastProvider } from '@app/components/toast/ToastContext';
import ToastRenderer from '@app/components/toast/ToastRenderer';
@@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer };
// Global imperative API via module singleton
let _api: ReturnType<typeof createImperativeApi> | null = null;
type ToastContextApi = ToastApi & { toasts: ToastInstance[] };
function createImperativeApi() {
const subscribers: Array<(fn: any) => void> = [];
let api: any = null;
const subscribers: Array<(fn: ToastContextApi) => void> = [];
let api: ToastContextApi | null = null;
return {
provide(instance: any) {
provide(instance: ToastContextApi) {
api = instance;
subscribers.splice(0).forEach(cb => cb(api));
subscribers.splice(0).forEach(cb => cb(instance));
},
get(): ToastContextApi | null {
return api;
},
onReady(cb: (readyApi: ToastContextApi) => void) {
if (api) {
cb(api);
} else {
subscribers.push(cb);
}
},
get(): any | null { return api; },
onReady(cb: (api: any) => void) {
if (api) cb(api); else subscribers.push(cb);
}
};
}
@@ -58,4 +66,3 @@ export function dismissAllToasts() {
_api?.get()?.dismissAll();
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { ReactNode } from 'react';
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'bottom-center';
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
export interface ToastOptions {
@@ -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)
@@ -13,14 +13,14 @@ export const useOverlayPdfsTips = (): TooltipContent => {
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
description: t(
'overlay-pdfs.tooltip.description.text',
'Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
'Combine a original PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
)
},
{
title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'),
description: t(
'overlay-pdfs.tooltip.mode.text',
'Choose how to distribute overlay pages across the base PDF pages.'
'Choose how to distribute overlay pages across the original PDF pages.'
),
bullets: [
t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'),
@@ -39,7 +39,7 @@ export const useOverlayPdfsTips = (): TooltipContent => {
title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'),
description: t(
'overlay-pdfs.tooltip.overlayFiles.text',
'Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
'Select one or more PDFs to overlay on the original. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
)
},
{
@@ -62,8 +62,8 @@ export function useViewerRightRailButtons() {
render: ({ disabled }) => (
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanning ? 'filled' : 'subtle'}
color={isPanning ? 'blue' : undefined}
variant={isPanning ? 'default' : 'subtle'}
color={undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
@@ -71,6 +71,7 @@ export function useViewerRightRailButtons() {
setIsPanning(prev => !prev);
}}
disabled={disabled}
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>