pushing final tweaks around scolling and addition/deletion dropdown filtering

This commit is contained in:
EthanHealy01 2025-10-27 12:20:21 +00:00
parent 227a974a09
commit 9801724232
8 changed files with 107 additions and 18 deletions

View File

@ -1992,6 +1992,7 @@
"2": "Document 2"
},
"submit": "Compare",
"newLine": "new-line",
"complex": {
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
},

View File

@ -1321,6 +1321,7 @@
"removed": "Removed from base",
"added": "Added in comparison"
},
"newLine": "new-line",
"summary": {
"baseHeading": "Base document",
"comparisonHeading": "Comparison document",

View File

@ -229,7 +229,7 @@ const CompareDocumentPane = ({
style={{ width: `${Math.round(page.width * fit)}px` }}
>
<div
className="compare-diff-page__inner"
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
style={{ transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`, transformOrigin: 'top left' }}
>
<img

View File

@ -1,5 +1,6 @@
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface NavigationDropdownProps {
changes: Array<{ value: string; label: string; pageNumber?: number }>;
@ -14,6 +15,8 @@ const CompareNavigationDropdown = ({
className,
onNavigate,
}: NavigationDropdownProps) => {
const { t } = useTranslation();
const newLineLabel = t('compare.newLine', 'new-line');
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
});
@ -50,13 +53,21 @@ const CompareNavigationDropdown = ({
const [query, setQuery] = useState('');
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: sanitize(c.label), pageNumber: c.pageNumber }))
.filter((c) => isMeaningful(c.label));
.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]);
}, [changes, query, newLineLabel]);
return (
<Combobox

View File

@ -72,6 +72,39 @@ export const useCompareChangeNavigation = (
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'smooth' });
// 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 peerDesiredTop = Math.max(
0,
peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2
);
peer.scrollTo({ top: peerDesiredTop, behavior: 'smooth' });
} else if (peerPageEl) {
// Fallback: Scroll to page top
const top = Math.max(0, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2));
peer.scrollTo({ top, behavior: 'smooth' });
}
}
}
const groupsByInner = new Map<HTMLElement, HTMLElement[]>();
nodes.forEach((element) => {
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;

View File

@ -7,6 +7,7 @@ import type {
import type { CompareChangeOption } from '../../../../types/compareWorkbench';
import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews';
import type { WordHighlightEntry } from '../types';
import { PARAGRAPH_SENTINEL } from '../../../../types/compare';
interface TokenGroupMap {
base: Map<number, string>;
@ -42,22 +43,32 @@ const buildWordChanges = (
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token.type === targetType) {
const startIndex = metadataIndex;
const parts: string[] = [];
const runIndices: number[] = [];
const pageNumber = metadata[metadataIndex]?.page ?? 1;
while (i < tokens.length && tokens[i].type === targetType) {
parts.push(tokens[i].text);
runIndices.push(metadataIndex);
const t = tokens[i].text;
const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA');
// Skip paragraph sentinel tokens entirely from labels and grouping
if (!isPara) {
parts.push(t);
// Only add to grouping if there is a corresponding metadata index
if (metadata[metadataIndex]) {
runIndices.push(metadataIndex);
}
}
metadataIndex += 1;
i += 1;
}
i -= 1;
const endIndex = metadataIndex - 1;
const groupId = `${groupPrefix}-${startIndex}-${endIndex}`;
runIndices.forEach((idx) => tokenIndexToGroupId.set(idx, groupId));
const label = parts.join(' ').trim();
items.push({ value: groupId, label: label || '(…)', pageNumber });
if (label.length > 0 && runIndices.length > 0) {
const startIndexForId = runIndices[0];
const endIndexForId = runIndices[runIndices.length - 1];
const groupId = `${groupPrefix}-${startIndexForId}-${endIndexForId}`;
runIndices.forEach((idx) => tokenIndexToGroupId.set(idx, groupId));
items.push({ value: groupId, label, pageNumber });
}
continue;
}
if (token.type !== (targetType === 'added' ? 'removed' : 'added')) {

View File

@ -29,6 +29,13 @@ interface ScrollLinkDelta {
horizontal: number;
}
// Pixel-based anchors captured when linking scroll, to preserve the
// visual offset between panes and avoid an initial snap.
interface ScrollLinkAnchors {
deltaPixelsBaseToComp: number;
deltaPixelsCompToBase: number;
}
interface PanDragState {
active: boolean;
source: Pane | null;
@ -93,6 +100,10 @@ export const useComparePanZoom = ({
const comparisonScrollRef = useRef<HTMLDivElement>(null);
const isSyncingRef = useRef(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>({
@ -328,12 +339,16 @@ export const useComparePanZoom = ({
lastActivePaneRef.current = source === baseScrollRef.current ? 'base' : 'comparison';
const sourceIsBase = source === baseScrollRef.current;
const deltaV = scrollLinkDeltaRef.current.vertical;
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
const offset = deltaV * targetVerticalRange;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + (sourceIsBase ? offset : -offset)));
// Use pixel anchors captured at link time to preserve offset
const deltaPx = sourceIsBase
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
isSyncingRef.current = true;
target.scrollTop = desiredTop;
@ -623,6 +638,7 @@ export const useComparePanZoom = ({
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);
@ -639,10 +655,19 @@ export const useComparePanZoom = ({
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 }), []);

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
import { Box, Group, Stack, Text, Button } from '@mantine/core';
import { Tooltip } from '../components/shared/Tooltip';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
import { BaseToolProps, ToolComponent } from '../types/tool';
@ -22,6 +23,7 @@ import type { StirlingFile } from '../types/fileContext';
import DocumentThumbnail from '../components/shared/filePreview/DocumentThumbnail';
import './compareTool.css';
import type { CompareWorkbenchData } from '../types/compareWorkbench';
import FitText from '../components/shared/FitText';
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
@ -234,9 +236,14 @@ const Compare = (props: BaseToolProps) => {
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
</Box>
<Stack gap={4} className="compare-tool__details">
<Text fw={600} truncate>
{stub?.name}
</Text>
<Tooltip content={stub?.name || ''} position="top" arrow>
<FitText
text={stub?.name || ''}
minimumFontScale={0.5}
lines={2}
style={{ fontWeight: 600 }}
/>
</Tooltip>
{meta && (
<Text size="sm" c="dimmed">
{meta}