pan and zoom are improved, still a couple of improvements required

This commit is contained in:
EthanHealy01 2025-10-23 14:05:43 +01:00
parent 3d71735dd8
commit 5da8d400a7
8 changed files with 574 additions and 624 deletions

View File

@ -0,0 +1,223 @@
import { Alert, Group, Loader, Stack, Text } from '@mantine/core';
import { MutableRefObject } from 'react';
import type { PagePreview, WordHighlightEntry } from './types';
import type { TokenBoundingBox } from '../../../types/compare';
import CompareNavigationDropdown from './CompareNavigationDropdown';
import { toRgba } from './compareUtils';
interface CompareDocumentPaneProps {
pane: 'base' | 'comparison';
scrollRef: MutableRefObject<HTMLDivElement | null>;
peerScrollRef: MutableRefObject<HTMLDivElement | null>;
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
beginPan: (pane: 'base' | 'comparison', event: React.MouseEvent<HTMLDivElement>) => void;
continuePan: (event: React.MouseEvent<HTMLDivElement>) => void;
endPan: () => void;
handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent<HTMLDivElement>) => void;
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
onTouchEnd: (event: React.TouchEvent<HTMLDivElement>) => void;
isPanMode: boolean;
zoom: number;
pan?: { x: number; y: number };
title: string;
dropdownPlaceholder?: string;
changes: Array<{ value: string; label: string }>;
onNavigateChange: (id: string) => void;
isLoading: boolean;
processingMessage: string;
emptyMessage: string;
pages: PagePreview[];
pairedPages: PagePreview[];
getRowHeightPx: (pageNumber: number) => number;
highlightColor: string;
highlightOpacity: number;
offsetPixels: number;
wordHighlightMap: Map<number, WordHighlightEntry[]>;
tokenIndexToGroupId: Map<number, string>;
documentLabel: string;
pageLabel: string;
altLabel: string;
}
const mergeSameLineRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
if (rects.length === 0) {
return rects;
}
const EPS_X = 0.02;
const EPS_Y = 0.006;
const sorted = rects
.slice()
.sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
const merged: TokenBoundingBox[] = [];
for (const rect of sorted) {
const last = merged[merged.length - 1];
if (last && Math.abs(rect.top - last.top) < EPS_Y && rect.left <= last.left + last.width + EPS_X) {
const left = Math.min(last.left, rect.left);
const right = Math.max(last.left + last.width, rect.left + rect.width);
const top = Math.min(last.top, rect.top);
const bottom = Math.max(last.top + last.height, rect.top + rect.height);
last.left = left;
last.top = top;
last.width = Math.max(0, right - left);
last.height = Math.max(0, bottom - top);
} else {
merged.push({ ...rect });
}
}
return merged;
};
const CompareDocumentPane = ({
pane,
scrollRef,
peerScrollRef,
handleScrollSync,
beginPan,
continuePan,
endPan,
handleWheelZoom,
onTouchStart,
onTouchMove,
onTouchEnd,
isPanMode,
zoom,
pan,
title,
dropdownPlaceholder,
changes,
onNavigateChange,
isLoading,
processingMessage,
emptyMessage,
pages,
pairedPages,
getRowHeightPx,
highlightColor,
highlightOpacity,
offsetPixels,
wordHighlightMap,
tokenIndexToGroupId,
documentLabel,
pageLabel,
altLabel,
}: CompareDocumentPaneProps) => {
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
const panX = (pan?.x ?? 0);
const panY = (pan?.y ?? 0);
return (
<div className="compare-pane">
<div className="compare-header">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">
{title}
</Text>
{changes.length > 0 && (
<CompareNavigationDropdown
changes={changes}
placeholder={dropdownPlaceholder ?? ''}
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
onNavigate={onNavigateChange}
/>
)}
</Group>
</div>
<div
ref={scrollRef}
onScroll={(event) => handleScrollSync(event.currentTarget, peerScrollRef.current)}
onMouseDown={(event) => beginPan(pane, event)}
onMouseMove={continuePan}
onMouseUp={endPan}
onMouseLeave={endPan}
onWheel={(event) => handleWheelZoom(pane, event)}
onTouchStart={(event) => onTouchStart(pane, event)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
className="compare-pane__scroll"
style={{ cursor: cursorStyle }}
>
<Stack gap="sm" className="compare-pane__content">
{isLoading && (
<Group justify="center" gap="xs" py="md">
<Loader size="sm" />
<Text size="sm">{processingMessage}</Text>
</Group>
)}
{!isLoading && pages.length === 0 && (
<Alert color="gray" variant="light">
<Text size="sm">{emptyMessage}</Text>
</Alert>
)}
{pages.map((page) => {
const peerPage = pairedPages.find((item) => item.pageNumber === page.pageNumber);
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
const fit = targetHeight / page.height;
const rowHeightPx = getRowHeightPx(page.pageNumber);
const highlightOffset = offsetPixels / page.height;
const wordRects = wordHighlightMap.get(page.pageNumber) ?? [];
const groupedRects = new Map<string, TokenBoundingBox[]>();
for (const { rect, index } of wordRects) {
const id =
tokenIndexToGroupId.get(index) ?? `${pane}-token-${index}`;
const current = groupedRects.get(id) ?? [];
current.push(rect);
groupedRects.set(id, current);
}
return (
<div
key={`${pane}-page-${page.pageNumber}`}
className="compare-diff-page"
style={{ minHeight: `${rowHeightPx}px` }}
>
<Text size="xs" fw={600} c="dimmed">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={{ width: `${Math.round(page.width * fit)}px` }}
>
<div
className="compare-diff-page__inner"
style={{ transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`, transformOrigin: 'top left' }}
>
<img
src={page.url}
alt={altLabel}
loading="lazy"
className="compare-diff-page__image"
/>
{[...groupedRects.entries()].flatMap(([id, rects]) =>
mergeSameLineRects(rects).map((rect, index) => (
<span
key={`${pane}-highlight-${page.pageNumber}-${id}-${index}`}
data-change-id={id}
className="compare-diff-highlight"
style={{
left: `${rect.left * 100}%`,
top: `${(rect.top + highlightOffset) * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
backgroundColor: toRgba(highlightColor, highlightOpacity),
}}
/>
))
)}
</div>
</div>
</div>
);
})}
</Stack>
</div>
</div>
);
};
export default CompareDocumentPane;

View File

@ -0,0 +1,66 @@
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
interface NavigationDropdownProps {
changes: Array<{ value: string; label: string }>;
placeholder: string;
className?: string;
onNavigate: (value: string) => void;
}
const CompareNavigationDropdown = ({
changes,
placeholder,
className,
onNavigate,
}: NavigationDropdownProps) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(value) => {
onNavigate(value);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<div
className={['compare-changes-select', className].filter(Boolean).join(' ')}
onClick={() => combobox.toggleDropdown()}
>
<span>{placeholder}</span>
<Combobox.Chevron />
</div>
</Combobox.Target>
<Combobox.Dropdown>
<ScrollArea.Autosize mah={300}>
<Combobox.Search placeholder="Search changes..." />
<Combobox.Options>
{changes.length > 0 ? (
changes.map((item) => (
<Combobox.Option
value={item.value}
key={item.value}
onClick={() => {
onNavigate(item.value);
combobox.closeDropdown();
}}
>
{item.label}
</Combobox.Option>
))
) : (
<Combobox.Empty>No changes found</Combobox.Empty>
)}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
export default CompareNavigationDropdown;

View File

@ -1,5 +1,5 @@
import React, { ForwardedRef, JSX, MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Group, Loader, Stack, Text, Paper, Combobox, useCombobox, ScrollArea, ActionIcon } from '@mantine/core';
import { Alert, Button, Group, Loader, Stack, Text, Paper, ActionIcon } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import {
@ -8,7 +8,6 @@ import {
CompareResultData,
CompareTokenMetadata,
REMOVAL_HIGHLIGHT,
TokenBoundingBox,
} from '../../../types/compare';
import type { FileId } from '../../../types/file';
import type { StirlingFileStub, StirlingFile } from '../../../types/fileContext';
@ -23,8 +22,9 @@ import { useRightRailButtons } from '../../../hooks/useRightRailButtons';
import { alert } from '../../toast';
import type { ToastLocation } from '../../toast/types';
import { useMediaQuery } from '@mantine/hooks';
type ViewMode = 'diff';
import { toRgba } from './compareUtils';
import { PagePreview, WordHighlightEntry } from './types';
import CompareDocumentPane from './CompareDocumentPane';
interface CompareWorkbenchData {
result: CompareResultData | null;
@ -42,25 +42,6 @@ interface CompareWorkbenchViewProps {
data: CompareWorkbenchData | null;
}
interface PagePreview {
pageNumber: number;
width: number;
height: number;
url: string;
}
type HighlightMap = Map<number, TokenBoundingBox[]>;
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})`;
};
const renderInlineParagraph = (baseText: string, comparisonText: string, side: 'base' | 'comparison') => {
@ -110,110 +91,6 @@ const renderInlineParagraph = (baseText: string, comparisonText: string, side: '
);
};
// Reuse summary inline algorithm to generate absolute positioned overlays for PDF pages
const computeInlineWordRects = (
pageNumber: number,
side: 'base' | 'comparison',
tokens: CompareDiffToken[],
metadata: CompareTokenMetadata[]
) => {
const rects: { bbox: TokenBoundingBox; type: 'added' | 'removed' }[] = [];
let index = 0;
for (const token of tokens) {
const meta = metadata[index] ?? null;
if (token.type !== 'unchanged' && meta?.bbox && meta.page === pageNumber) {
rects.push({ bbox: meta.bbox, type: token.type === 'added' ? 'added' : 'removed' });
}
if (side === 'base' && token.type !== 'added') index += 1;
if (side === 'comparison' && token.type !== 'removed') index += 1;
}
return rects;
};
const buildHighlightMaps = (
tokens: CompareDiffToken[],
baseMetadata: CompareTokenMetadata[],
comparisonMetadata: CompareTokenMetadata[]
): { base: HighlightMap; comparison: HighlightMap } => {
const baseHighlights: HighlightMap = new Map();
const comparisonHighlights: HighlightMap = new Map();
let baseIndex = 0;
let comparisonIndex = 0;
tokens.forEach((token) => {
if (token.type === 'removed') {
if (baseIndex < baseMetadata.length) {
const meta = baseMetadata[baseIndex];
if (meta?.bbox) {
const entry = baseHighlights.get(meta.page) ?? [];
entry.push(meta.bbox);
baseHighlights.set(meta.page, entry);
}
}
baseIndex += 1;
return;
}
if (token.type === 'added') {
if (comparisonIndex < comparisonMetadata.length) {
const meta = comparisonMetadata[comparisonIndex];
if (meta?.bbox) {
const entry = comparisonHighlights.get(meta.page) ?? [];
entry.push(meta.bbox);
comparisonHighlights.set(meta.page, entry);
}
}
comparisonIndex += 1;
return;
}
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
});
// Merge overlapping/adjacent rectangles to avoid overpainting
const mergeRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
if (rects.length === 0) return rects;
const EPS_X = 0.004; // ~0.4% width tolerance
const EPS_Y = 0.006; // vertical tolerance to treat as same line
const sorted = rects.slice().sort((r1, r2) => (r1.top !== r2.top ? r1.top - r2.top : r1.left - r2.left));
const merged: TokenBoundingBox[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (
last && Math.abs(r.top - last.top) < EPS_Y &&
r.left <= last.left + last.width + EPS_X &&
r.top + r.height >= last.top - EPS_Y && last.top + last.height >= r.top - EPS_Y
) {
const left = Math.min(last.left, r.left);
const right = Math.max(last.left + last.width, r.left + r.width);
const top = Math.min(last.top, r.top);
const bottom = Math.max(last.top + last.height, r.top + r.height);
last.left = left;
last.top = top;
last.width = Math.max(0, right - left);
last.height = Math.max(0, bottom - top);
} else {
merged.push({ ...r });
}
}
return merged;
};
for (const [page, rects] of baseHighlights) {
baseHighlights.set(page, mergeRects(rects));
}
for (const [page, rects] of comparisonHighlights) {
comparisonHighlights.set(page, mergeRects(rects));
}
return { base: baseHighlights, comparison: comparisonHighlights };
};
const renderPdfDocumentToImages = async (file: File): Promise<PagePreview[]> => {
const arrayBuffer = await file.arrayBuffer();
@ -264,7 +141,6 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const { openFilesModal } = useFilesModalContext();
const { actions: fileActions } = useFileActions();
const { selectors } = useFileContext();
const [mode, setMode] = useState<ViewMode>('diff');
const prefersStacked = useMediaQuery('(max-width: 1024px)');
const [layout, setLayout] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
@ -280,10 +156,14 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
// Maintain normalized deltas so panes keep their relative positions when re-linked
const scrollLinkDeltaRef = useRef<{ vertical: number; horizontal: number }>({ vertical: 0, horizontal: 0 });
const [isPanMode, setIsPanMode] = useState(false);
const panDragRef = useRef<{ active: boolean; source: 'base' | 'comparison' | null; startX: number; startY: number; startScrollLeft: number; startScrollTop: number; targetStartScrollLeft: number; targetStartScrollTop: number }>({ active: false, source: null, startX: 0, startY: 0, startScrollLeft: 0, startScrollTop: 0, targetStartScrollLeft: 0, targetStartScrollTop: 0 });
const panDragRef = useRef<{ active: boolean; source: 'base' | 'comparison' | null; startX: number; startY: number; startPanX: number; startPanY: number; targetStartPanX: number; targetStartPanY: number }>(
{ active: false, source: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0, targetStartPanX: 0, targetStartPanY: 0 }
);
const lastActivePaneRef = useRef<'base' | 'comparison'>('base');
const [baseZoom, setBaseZoom] = useState(1);
const [comparisonZoom, setComparisonZoom] = useState(1);
const [basePan, setBasePan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [comparisonPan, setComparisonPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3;
const ZOOM_STEP = 0.1;
@ -634,10 +514,6 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
</Stack>
);
useEffect(() => {
setMode('diff');
}, [result?.totals.processedAt, data?.baseFileId, data?.comparisonFileId]);
useEffect(() => {
let cancelled = false;
@ -712,70 +588,13 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
processedAt: Date.now(),
};
const highlightMaps = useMemo(() => {
if (!result) {
return { base: new Map(), comparison: new Map() };
}
// Build per-page rectangles directly from the token stream we used for summary
const baseMap: HighlightMap = new Map();
const comparisonMap: HighlightMap = new Map();
// Precompute rects for each page using token metadata
const byPageBase: Map<number, TokenBoundingBox[]> = new Map();
const byPageComparison: Map<number, TokenBoundingBox[]> = new Map();
const processingMessage = t('compare.status.processing', 'Analyzing differences...');
const emptyMessage = t('compare.view.noData', 'Run a comparison to view the summary and diff.');
const baseDocumentLabel = t('compare.summary.baseHeading', 'Base document');
const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Comparison document');
const pageLabel = t('compare.summary.pageLabel', 'Page');
let baseIndex = 0;
let comparisonIndex = 0;
for (const token of result.tokens) {
if (token.type === 'removed') {
const meta = result.tokenMetadata.base[baseIndex];
if (meta?.bbox) {
const arr = byPageBase.get(meta.page) ?? [];
arr.push(meta.bbox);
byPageBase.set(meta.page, arr);
}
baseIndex += 1;
} else if (token.type === 'added') {
const meta = result.tokenMetadata.comparison[comparisonIndex];
if (meta?.bbox) {
const arr = byPageComparison.get(meta.page) ?? [];
arr.push(meta.bbox);
byPageComparison.set(meta.page, arr);
}
comparisonIndex += 1;
} else {
baseIndex += 1;
comparisonIndex += 1;
}
}
const toMerged = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
const EPS_X = 0.02; // merge gaps up to ~2% of page width
const EPS_Y = 0.006;
const sorted = rects.slice().sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
const merged: TokenBoundingBox[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (
last && Math.abs(r.top - last.top) < EPS_Y && r.left <= last.left + last.width + EPS_X
) {
const left = Math.min(last.left, r.left);
const right = Math.max(last.left + last.width, r.left + r.width);
last.left = left;
last.width = Math.max(0, right - left);
last.top = Math.min(last.top, r.top);
last.height = Math.max(last.height, r.height);
} else {
merged.push({ ...r });
}
}
return merged;
};
for (const [page, rects] of byPageBase) baseMap.set(page, toMerged(rects));
for (const [page, rects] of byPageComparison) comparisonMap.set(page, toMerged(rects));
return { base: baseMap, comparison: comparisonMap };
}, [result]);
const handleScrollSync = (source: HTMLDivElement | null, target: HTMLDivElement | null) => {
// Do not sync while panning; panning should only affect the active pane
@ -789,49 +608,79 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const sourceIsBase = source === baseScrollRef.current;
const deltaV = scrollLinkDeltaRef.current.vertical;
const deltaH = scrollLinkDeltaRef.current.horizontal;
// Normalize positions (guard against zero scroll ranges)
const sVMax = Math.max(1, source.scrollHeight - source.clientHeight);
const sHMax = Math.max(1, source.scrollWidth - source.clientWidth);
const tVMax = Math.max(1, target.scrollHeight - target.clientHeight);
const tHMax = Math.max(1, target.scrollWidth - target.clientWidth);
// If target cannot scroll vertically, skip syncing to avoid jumps
if (tVMax <= 1) {
return;
}
const sV = source.scrollTop / sVMax;
const sH = source.scrollLeft / sHMax;
// If base is source, comp = base + delta; if comp is source, base = comp - delta
const desiredTV = sourceIsBase ? sV + deltaV : sV - deltaV;
const desiredTH = sourceIsBase ? sH + deltaH : sH - deltaH;
// Only sync vertical scrolling to avoid layout-induced jumps with differing orientations
const clampedTV = Math.max(0, Math.min(1, desiredTV));
const clampedTH = Math.max(0, Math.min(1, desiredTH));
// Horizontal sync disabled intentionally
isSyncingRef.current = true;
target.scrollTop = clampedTV * tVMax;
target.scrollLeft = clampedTH * tHMax;
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
};
const getMaxCanvasSize = useCallback((pane: 'base' | 'comparison') => {
const pages = pane === 'base' ? basePages : comparisonPages;
const peers = pane === 'base' ? comparisonPages : basePages;
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 };
}, [basePages, comparisonPages]);
const getPanBounds = useCallback((pane: 'base' | 'comparison') => {
const { maxW, maxH } = getMaxCanvasSize(pane);
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
const extraX = Math.max(0, Math.round(maxW * (zoom - 1)));
const extraY = Math.max(0, Math.round(maxH * (zoom - 1)));
return { maxX: extraX, maxY: extraY };
}, [getMaxCanvasSize, baseZoom, comparisonZoom]);
const beginPan = (pane: 'base' | 'comparison', e: React.MouseEvent<HTMLDivElement>) => {
if (!isPanMode) return;
// Only enable panning when zoomed beyond 1 (i.e., content larger than viewport)
if (pane === 'base' ? baseZoom <= 1 : comparisonZoom <= 1) return;
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
if (zoom <= 1) return;
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
const other = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
if (!container) return;
// Only start inner-content panning when the drag starts over the image content
const targetEl = e.target as HTMLElement | null;
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
if (!isOnImage) return; // allow normal scrolling outside the image
e.preventDefault();
panDragRef.current = {
active: true,
source: pane,
startX: e.clientX,
startY: e.clientY,
startScrollLeft: container.scrollLeft,
startScrollTop: container.scrollTop,
targetStartScrollLeft: other?.scrollLeft ?? 0,
targetStartScrollTop: other?.scrollTop ?? 0,
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,
};
lastActivePaneRef.current = pane;
(container as HTMLDivElement).style.cursor = 'grabbing';
@ -842,34 +691,24 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const drag = panDragRef.current;
if (!drag.active || !drag.source) return;
const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
const targetEl = drag.source === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
if (!sourceEl) return;
const dx = e.clientX - drag.startX;
const dy = e.clientY - drag.startY;
isSyncingRef.current = true;
sourceEl.scrollLeft = drag.startScrollLeft - dx;
sourceEl.scrollTop = drag.startScrollTop - dy;
const isBase = drag.source === 'base';
const bounds = getPanBounds(drag.source);
const nextX = Math.max(0, Math.min(bounds.maxX, drag.startPanX - dx));
const nextY = Math.max(0, Math.min(bounds.maxY, drag.startPanY - dy));
if (isBase) setBasePan({ x: nextX, y: nextY }); else setComparisonPan({ x: nextX, y: nextY });
// If linked, pan the other pane proportionally; else only pan the active pane
if (isScrollLinked && targetEl) {
const sHMax = Math.max(1, sourceEl.scrollWidth - sourceEl.clientWidth);
const tHMax = Math.max(1, targetEl.scrollWidth - targetEl.clientWidth);
const sVMax = Math.max(1, sourceEl.scrollHeight - sourceEl.clientHeight);
const tVMax = Math.max(1, targetEl.scrollHeight - targetEl.clientHeight);
const scaledDx = dx * (tHMax / sHMax);
const scaledDy = dy * (tVMax / sVMax);
targetEl.scrollLeft = panDragRef.current.targetStartScrollLeft - scaledDx;
targetEl.scrollTop = panDragRef.current.targetStartScrollTop - scaledDy;
if (isScrollLinked) {
const otherPane: 'base' | 'comparison' = isBase ? 'comparison' : 'base';
const otherBounds = getPanBounds(otherPane);
const scaleX = bounds.maxX > 0 ? otherBounds.maxX / bounds.maxX : 0;
const scaleY = bounds.maxY > 0 ? otherBounds.maxY / bounds.maxY : 0;
const otherNextX = Math.max(0, Math.min(otherBounds.maxX, panDragRef.current.targetStartPanX - dx * scaleX));
const otherNextY = Math.max(0, Math.min(otherBounds.maxY, panDragRef.current.targetStartPanY - dy * scaleY));
if (isBase) setComparisonPan({ x: otherNextX, y: otherNextY }); else setBasePan({ x: otherNextX, y: otherNextY });
}
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
};
const endPan = () => {
@ -922,28 +761,70 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
startZoom: pane === 'base' ? baseZoom : comparisonZoom,
};
e.preventDefault();
} else if (e.touches.length === 1) {
if (!isPanMode) return;
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
if (zoom <= 1) return;
const targetEl = e.target as HTMLElement | null;
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
if (!isOnImage) return;
const touch = e.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,
};
e.preventDefault();
}
};
const onTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (!pinchRef.current.active || e.touches.length !== 2) return;
const [t1, t2] = [e.touches[0], e.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);
// Dampen sensitivity
const dampened = 1 + (scale - 1) * 0.6;
const pane = pinchRef.current.pane!;
const startZoom = pinchRef.current.startZoom;
const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
if (pane === 'base') setBaseZoom(nextZoom); else setComparisonZoom(nextZoom);
e.preventDefault();
if (pinchRef.current.active && e.touches.length === 2) {
const [t1, t2] = [e.touches[0], e.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 nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
if (pane === 'base') setBaseZoom(nextZoom); else setComparisonZoom(nextZoom);
e.preventDefault();
return;
}
// One-finger pan
if (panDragRef.current.active && e.touches.length === 1) {
const touch = e.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 nextX = Math.max(0, Math.min(bounds.maxX, panDragRef.current.startPanX - dx));
const nextY = Math.max(0, Math.min(bounds.maxY, panDragRef.current.startPanY - dy));
if (isBase) setBasePan({ x: nextX, y: nextY }); else setComparisonPan({ x: nextX, y: nextY });
if (isScrollLinked) {
const otherPane: 'base' | 'comparison' = isBase ? 'comparison' : 'base';
const otherBounds = getPanBounds(otherPane);
const scaleX = bounds.maxX > 0 ? otherBounds.maxX / bounds.maxX : 0;
const scaleY = bounds.maxY > 0 ? otherBounds.maxY / bounds.maxY : 0;
const otherNextX = Math.max(0, Math.min(otherBounds.maxX, panDragRef.current.targetStartPanX - dx * scaleX));
const otherNextY = Math.max(0, Math.min(otherBounds.maxY, panDragRef.current.targetStartPanY - dy * scaleY));
if (isBase) setComparisonPan({ x: otherNextX, y: otherNextY }); else setBasePan({ x: otherNextX, y: otherNextY });
}
e.preventDefault();
}
};
const onTouchEnd = () => {
pinchRef.current.active = false;
pinchRef.current.pane = null;
panDragRef.current.active = false;
};
// Keyboard handler: when unlinked, ArrowUp/Down scroll both panes in the same direction
@ -1129,9 +1010,14 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
// Precompute word highlight rects by page with their token indices (no merging)
const wordHighlightMaps = useMemo(() => {
if (!result) return { base: new Map<number, { rect: TokenBoundingBox; index: number }[]>(), comparison: new Map<number, { rect: TokenBoundingBox; index: number }[]>() };
const baseMap = new Map<number, { rect: TokenBoundingBox; index: number }[]>();
const comparisonMap = new Map<number, { rect: TokenBoundingBox; index: number }[]>();
if (!result) {
return {
base: new Map<number, WordHighlightEntry[]>(),
comparison: new Map<number, WordHighlightEntry[]>(),
};
}
const baseMap = new Map<number, WordHighlightEntry[]>();
const comparisonMap = new Map<number, WordHighlightEntry[]>();
let baseIndex = 0;
let comparisonIndex = 0;
@ -1165,11 +1051,12 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
const getRowHeightPx = useCallback((pageNumber: number) => {
const basePage = basePages.find(p => p.pageNumber === pageNumber);
const compPage = comparisonPages.find(p => p.pageNumber === pageNumber);
const baseHeight = basePage ? basePage.height * baseZoom : 0;
const compHeight = compPage ? compPage.height * comparisonZoom : 0;
// Row height must remain constant regardless of zoom.
const baseHeight = basePage ? basePage.height : 0;
const compHeight = compPage ? compPage.height : 0;
const rowHeight = Math.max(baseHeight, compHeight);
return Math.round(rowHeight);
}, [basePages, comparisonPages, baseZoom, comparisonZoom]);
}, [basePages, comparisonPages]);
const handleChangeNavigation = useCallback((changeValue: string, pane: 'base' | 'comparison') => {
const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef;
@ -1216,80 +1103,6 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
}
}, []);
// Custom navigation dropdown component
const NavigationDropdown = ({
changes,
placeholder,
className,
onNavigate,
}: {
changes: Array<{ value: string; label: string; pageNumber: number }>;
placeholder: string;
className?: string;
onNavigate: (value: string) => void;
}) => {
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
const options = changes.map((item) => (
<Combobox.Option
value={item.value}
key={item.value}
onClick={() => {
console.log('Dropdown option clicked:', item.value);
onNavigate(item.value);
combobox.closeDropdown();
}}
>
<span style={{ fontSize: '0.875rem' }}>{item.label}</span>
</Combobox.Option>
));
return (
<Combobox
store={combobox}
withinPortal={false}
onOptionSubmit={(val) => {
console.log('Dropdown option submitted:', val);
onNavigate(val);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<div
className={`compare-changes-select ${className || ''}`}
style={{
cursor: 'pointer',
minWidth: '200px',
padding: '6px 12px',
borderRadius: '8px',
fontSize: '0.875rem',
fontWeight: 500,
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
onClick={() => combobox.toggleDropdown()}
>
<span>{placeholder}</span>
<Combobox.Chevron style={{ marginLeft: '8px' }} />
</div>
</Combobox.Target>
<Combobox.Dropdown>
<ScrollArea.Autosize mah={300}>
<Combobox.Search placeholder="Search changes..." />
<Combobox.Options>
{options.length > 0 ? options : <Combobox.Empty>No changes found</Combobox.Empty>}
</Combobox.Options>
</ScrollArea.Autosize>
</Combobox.Dropdown>
</Combobox>
);
};
// Drag-to-pan: adjust scroll positions directly for smooth, synced panning
// No pan/zoom handlers in simplified mode
@ -1297,8 +1110,14 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
return renderUploadLayout();
}
// Safe to access result and the computed arrays below this point
const baseTitle = `${result.base.fileName} - ${basePages.length} pages`;
const comparisonTitle = `${result.comparison.fileName} - ${comparisonPages.length} pages`;
const baseDropdownPlaceholder = `Deletions (${baseWordChanges.length})`;
const comparisonDropdownPlaceholder = `Additions (${comparisonWordChanges.length})`;
return (
<Stack className="compare-workbench" style={{ height: '100%', minHeight: 0 }}>
<Stack className="compare-workbench">
{result.warnings.length > 0 && (
<Alert color="yellow" variant="light">
<Stack gap={4}>
@ -1312,295 +1131,79 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
)}
{/* Diff view only */}
<Stack gap="lg" style={{ flex: 1, minHeight: 0 }}>
<Group gap="md" className="compare-legend">
<div className="compare-legend__item">
<span className="compare-legend__swatch" style={{ backgroundColor: toRgba(result.base.highlightColor, 0.35) }} />
<Text size="xs">{t('compare.legend.removed', 'Removed from base')}</Text>
</div>
<div className="compare-legend__item">
<span
className="compare-legend__swatch"
style={{ backgroundColor: toRgba(result.comparison.highlightColor, 0.35) }}
/>
<Text size="xs">{t('compare.legend.added', 'Added in comparison')}</Text>
</div>
</Group>
<Stack gap="lg" className="compare-workbench__content">
<div
className={`compare-workbench__columns ${layout === 'stacked' ? 'compare-workbench__columns--stacked' : ''}`}
style={{
minHeight: 0,
height: '100%',
}}
>
{/** Compute pane styles: in stacked mode, force each pane to exactly half height */}
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Sticky Header - Outside scroll container */}
<div className="compare-header">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">
{result.base.fileName} - {basePages.length} pages
</Text>
{baseWordChanges.length > 0 && (
<NavigationDropdown
changes={baseWordChanges}
placeholder={`Deletions (${baseWordChanges.length})`}
className=""
onNavigate={(value) => handleChangeNavigation(value, 'base')}
/>
)}
</Group>
</div>
{/* Scrollable content */}
<div
ref={baseScrollRef}
onScroll={(event) => handleScrollSync(event.currentTarget, comparisonScrollRef.current)}
onMouseDown={(e) => beginPan('base', e)}
onMouseMove={continuePan}
onMouseUp={endPan}
onMouseLeave={endPan}
onWheel={(e) => handleWheelZoom('base', e)}
onTouchStart={(e) => onTouchStart('base', e)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{ minHeight: 0, flex: 1, overflow: 'auto', cursor: isPanMode ? (baseZoom > 1 ? 'grab' : 'auto') : 'auto' }}
>
<Stack gap="sm" style={{ position: 'relative' }}>
{baseLoading && (
<Group justify="center" gap="xs" py="md">
<Loader size="sm" />
<Text size="sm">{t('compare.status.processing', 'Analyzing differences...')}</Text>
</Group>
)}
{!baseLoading && basePages.length === 0 && (
<Alert color="gray" variant="light">
<Text size="sm">{t('compare.view.noData', 'Run a comparison to view the summary and diff.')}</Text>
</Alert>
)}
{basePages.map((page) => {
const highlights = highlightMaps.base.get(page.pageNumber) ?? [];
const vOffset = 4 / page.height; // ~2px downward adjustment
// Compute a per-row fit so both sides share the same (larger) height for this page number
const compPage = comparisonPages.find(p => p.pageNumber === page.pageNumber);
const targetHeight = compPage ? Math.max(page.height, compPage.height) : page.height;
const fit = targetHeight / page.height;
const rowHeightPx = getRowHeightPx(page.pageNumber);
return (
<div key={`base-page-${page.pageNumber}`} className="compare-diff-page" style={{ minHeight: `${rowHeightPx}px` }}>
<Text size="xs" fw={600} c="dimmed">
{t('compare.summary.baseHeading', 'Base document')} · {t('compare.summary.pageLabel', 'Page')} {page.pageNumber}
</Text>
<div className="compare-diff-page__canvas" style={{ width: `${Math.round(page.width * fit * baseZoom)}px`, maxWidth: '100%', overflow: 'visible' }}>
<div style={{ position: 'relative', width: '100%', aspectRatio: `${page.width} / ${page.height}` }}>
<img src={page.url} alt={t('compare.summary.baseHeading', 'Base document')} loading="lazy" style={{ width: '100%', height: '100%' }} />
{(() => {
// Render per-word highlights with stable IDs
const wordRects = wordHighlightMaps.base.get(page.pageNumber) ?? [];
// Group rects by change id, then merge contiguous rects on the same line
const byGroup = new Map<string, TokenBoundingBox[]>();
for (const { rect, index } of wordRects) {
const id = baseTokenIndexToGroupId.get(index) ?? `base-token-${index}`;
const arr = byGroup.get(id) ?? [];
arr.push(rect);
byGroup.set(id, arr);
}
const EPS_X = 0.02; // merge gaps up to ~2% of page width
const EPS_Y = 0.006; // consider same text line
const mergeSameLine = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
if (rects.length === 0) return rects;
const sorted = rects.slice().sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
const merged: TokenBoundingBox[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (
last && Math.abs(r.top - last.top) < EPS_Y && r.left <= last.left + last.width + EPS_X
) {
const left = Math.min(last.left, r.left);
const right = Math.max(last.left + last.width, r.left + r.width);
const top = Math.min(last.top, r.top);
const bottom = Math.max(last.top + last.height, r.top + r.height);
last.left = left;
last.top = top;
last.width = Math.max(0, right - left);
last.height = Math.max(0, bottom - top);
} else {
merged.push({ ...r });
}
}
return merged;
};
const spans: JSX.Element[] = [];
byGroup.forEach((rects, id) => {
const mergedRects = mergeSameLine(rects);
mergedRects.forEach((rect, mIndex) => {
spans.push(
<span
key={`base-highlight-${page.pageNumber}-${id}-${mIndex}`}
data-change-id={id}
className="compare-diff-highlight"
style={{
left: `${rect.left * 100}%`,
top: `${(rect.top + vOffset) * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
backgroundColor: toRgba(REMOVAL_HIGHLIGHT, 0.45),
}}
/>
);
});
});
return spans;
})()}
</div>
</div>
</div>
);
})}
</Stack>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Sticky Header - Outside scroll container */}
<div className="compare-header">
<Group justify="space-between" align="center">
<Text fw={600} size="lg">
{result.comparison.fileName} - {comparisonPages.length} pages
</Text>
{comparisonWordChanges.length > 0 && (
<NavigationDropdown
changes={comparisonWordChanges}
placeholder={`Additions (${comparisonWordChanges.length})`}
className="compare-changes-select--comparison"
onNavigate={(value) => handleChangeNavigation(value, 'comparison')}
/>
)}
</Group>
</div>
{/* Scrollable content */}
<div
ref={comparisonScrollRef}
onScroll={(event) => handleScrollSync(event.currentTarget, baseScrollRef.current)}
onMouseDown={(e) => beginPan('comparison', e)}
onMouseMove={continuePan}
onMouseUp={endPan}
onMouseLeave={endPan}
onWheel={(e) => handleWheelZoom('comparison', e)}
onTouchStart={(e) => onTouchStart('comparison', e)}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{ minHeight: 0, flex: 1, overflow: 'auto', cursor: isPanMode ? 'grab' : 'auto' }}
>
<Stack gap="sm" style={{ position: 'relative' }}>
{comparisonLoading && (
<Group justify="center" gap="xs" py="md">
<Loader size="sm" />
<Text size="sm">{t('compare.status.processing', 'Analyzing differences...')}</Text>
</Group>
)}
{!comparisonLoading && comparisonPages.length === 0 && (
<Alert color="gray" variant="light">
<Text size="sm">{t('compare.view.noData', 'Run a comparison to view the summary and diff.')}</Text>
</Alert>
)}
{comparisonPages.map((page) => {
const highlights = highlightMaps.comparison.get(page.pageNumber) ?? [];
const vOffset = 2 / page.height; // ~2px downward adjustment
const basePage = basePages.find(p => p.pageNumber === page.pageNumber);
const targetHeight = basePage ? Math.max(page.height, basePage.height) : page.height;
const fit = targetHeight / page.height;
const rowHeightPx = getRowHeightPx(page.pageNumber);
return (
<div key={`comparison-page-${page.pageNumber}`} className="compare-diff-page" style={{ minHeight: `${rowHeightPx}px` }}>
<Text size="xs" fw={600} c="dimmed">
{t('compare.summary.comparisonHeading', 'Comparison document')} · {t('compare.summary.pageLabel', 'Page')}{' '}
{page.pageNumber}
</Text>
<div className="compare-diff-page__canvas" style={{ width: `${Math.round(page.width * fit * comparisonZoom)}px`, maxWidth: '100%', overflow: 'visible' }}>
<div style={{ position: 'relative', width: '100%', aspectRatio: `${page.width} / ${page.height}` }}>
<img src={page.url} alt={t('compare.summary.comparisonHeading', 'Comparison document')} loading="lazy" style={{ width: '100%', height: '100%' }} />
{(() => {
// Render per-word highlights with stable IDs
const wordRects = wordHighlightMaps.comparison.get(page.pageNumber) ?? [];
const byGroup = new Map<string, TokenBoundingBox[]>();
for (const { rect, index } of wordRects) {
const id = comparisonTokenIndexToGroupId.get(index) ?? `comparison-token-${index}`;
const arr = byGroup.get(id) ?? [];
arr.push(rect);
byGroup.set(id, arr);
}
const EPS_X = 0.02;
const EPS_Y = 0.006;
const mergeSameLine = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
if (rects.length === 0) return rects;
const sorted = rects.slice().sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
const merged: TokenBoundingBox[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (last && Math.abs(r.top - last.top) < EPS_Y && r.left <= last.left + last.width + EPS_X) {
const left = Math.min(last.left, r.left);
const right = Math.max(last.left + last.width, r.left + r.width);
const top = Math.min(last.top, r.top);
const bottom = Math.max(last.top + last.height, r.top + r.height);
last.left = left;
last.top = top;
last.width = Math.max(0, right - left);
last.height = Math.max(0, bottom - top);
} else {
merged.push({ ...r });
}
}
return merged;
};
const spans: JSX.Element[] = [];
byGroup.forEach((rects, id) => {
const mergedRects = mergeSameLine(rects);
mergedRects.forEach((rect, mIndex) => {
spans.push(
<span
key={`comparison-highlight-${page.pageNumber}-${id}-${mIndex}`}
data-change-id={id}
className="compare-diff-highlight"
style={{
left: `${rect.left * 100}%`,
top: `${(rect.top + vOffset) * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
backgroundColor: toRgba(ADDITION_HIGHLIGHT, 0.35),
}}
/>
);
});
});
return spans;
})()}
</div>
</div>
</div>
);
})}
</Stack>
</div>
</div>
<CompareDocumentPane
pane="base"
scrollRef={baseScrollRef}
peerScrollRef={comparisonScrollRef}
handleScrollSync={handleScrollSync}
beginPan={beginPan}
continuePan={continuePan}
endPan={endPan}
handleWheelZoom={handleWheelZoom}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
isPanMode={isPanMode}
zoom={baseZoom}
pan={basePan}
title={baseTitle}
dropdownPlaceholder={baseDropdownPlaceholder}
changes={baseWordChanges.map(({ value, label }) => ({ value, label }))}
onNavigateChange={(value) => handleChangeNavigation(value, 'base')}
isLoading={baseLoading}
processingMessage={processingMessage}
emptyMessage={emptyMessage}
pages={basePages}
pairedPages={comparisonPages}
getRowHeightPx={getRowHeightPx}
highlightColor={REMOVAL_HIGHLIGHT}
highlightOpacity={0.45}
offsetPixels={4}
wordHighlightMap={wordHighlightMaps.base}
tokenIndexToGroupId={baseTokenIndexToGroupId}
documentLabel={baseDocumentLabel}
pageLabel={pageLabel}
altLabel={baseDocumentLabel}
/>
<CompareDocumentPane
pane="comparison"
scrollRef={comparisonScrollRef}
peerScrollRef={baseScrollRef}
handleScrollSync={handleScrollSync}
beginPan={beginPan}
continuePan={continuePan}
endPan={endPan}
handleWheelZoom={handleWheelZoom}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
isPanMode={isPanMode}
zoom={comparisonZoom}
pan={comparisonPan}
title={comparisonTitle}
dropdownPlaceholder={comparisonDropdownPlaceholder}
changes={comparisonWordChanges.map(({ value, label }) => ({ value, label }))}
onNavigateChange={(value) => handleChangeNavigation(value, 'comparison')}
isLoading={comparisonLoading}
processingMessage={processingMessage}
emptyMessage={emptyMessage}
pages={comparisonPages}
pairedPages={basePages}
getRowHeightPx={getRowHeightPx}
highlightColor={ADDITION_HIGHLIGHT}
highlightOpacity={0.35}
offsetPixels={2}
wordHighlightMap={wordHighlightMaps.comparison}
tokenIndexToGroupId={comparisonTokenIndexToGroupId}
documentLabel={comparisonDocumentLabel}
pageLabel={pageLabel}
altLabel={comparisonDocumentLabel}
/>
</div>
</Stack>
</Stack>

View File

@ -0,0 +1,10 @@
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})`;
};

View File

@ -9,6 +9,27 @@
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;
}
.compare-pane__content {
position: relative;
}
.compare-workbench__mode {
align-self: center;
}
@ -63,6 +84,14 @@
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--comparison {
@ -164,12 +193,10 @@
}
/* Style the chevron - ensure proper coloring */
.compare-changes-select .mantine-Combobox-chevron {
color: inherit !important;
}
.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 */
@ -257,26 +284,23 @@
width: 100%;
}
/* Ensure the image scales to the container width without overflowing (handles rotation/landscape) */
.compare-diff-page__canvas img {
display: block;
.compare-diff-page__canvas--zoom {
overflow: hidden;
max-width: 100%;
width: 100%;
height: auto;
object-fit: contain;
}
/* Ensure inner page wrapper centers and scales within container */
.compare-diff-page__canvas > div {
.compare-diff-page__inner {
position: relative;
width: 100%;
margin-left: auto;
margin-right: auto;
max-width: 100%;
}
.compare-diff-page__canvas img {
.compare-diff-page__image {
display: block;
width: 100%;
height: auto;
height: 100%;
object-fit: contain;
}

View File

@ -0,0 +1,13 @@
import type { TokenBoundingBox } from '../../../types/compare';
export interface PagePreview {
pageNumber: number;
width: number;
height: number;
url: string;
}
export interface WordHighlightEntry {
rect: TokenBoundingBox;
index: number;
}

View File

@ -20,6 +20,7 @@ import type { FileId } from '../types/file';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import DocumentThumbnail from '../components/shared/filePreview/DocumentThumbnail';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import './compareTool.css';
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
@ -178,10 +179,10 @@ const Compare = (props: BaseToolProps) => {
return (
<Card withBorder padding="md" radius="md">
<Group align="flex-start" wrap="nowrap" gap="md">
<Box style={{ width: 64, height: 84, flexShrink: 0 }}>
<Box className="compare-tool__thumbnail">
<DocumentThumbnail file={stub as any} thumbnail={stub?.thumbnailUrl || null} />
</Box>
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Stack gap={4} className="compare-tool__details">
<Text fw={600} truncate>
{stub?.name}
</Text>

View File

@ -0,0 +1,10 @@
.compare-tool__thumbnail {
width: 4rem;
height: 5.25rem;
flex-shrink: 0;
}
.compare-tool__details {
flex: 1;
min-width: 0;
}