diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index f6f8d828c..4fdc5b79b 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -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"
},
diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json
index b57f6cf59..535b0fce4 100644
--- a/frontend/public/locales/en-US/translation.json
+++ b/frontend/public/locales/en-US/translation.json
@@ -1321,6 +1321,7 @@
"removed": "Removed from base",
"added": "Added in comparison"
},
+ "newLine": "new-line",
"summary": {
"baseHeading": "Base document",
"comparisonHeading": "Comparison document",
diff --git a/frontend/src/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/components/tools/compare/CompareDocumentPane.tsx
index 1dc9756ac..81253b247 100644
--- a/frontend/src/components/tools/compare/CompareDocumentPane.tsx
+++ b/frontend/src/components/tools/compare/CompareDocumentPane.tsx
@@ -229,7 +229,7 @@ const CompareDocumentPane = ({
style={{ width: `${Math.round(page.width * fit)}px` }}
>
![]()
;
@@ -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 (
();
nodes.forEach((element) => {
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;
diff --git a/frontend/src/components/tools/compare/hooks/useCompareHighlights.ts b/frontend/src/components/tools/compare/hooks/useCompareHighlights.ts
index f86f5d885..c0bf49f08 100644
--- a/frontend/src/components/tools/compare/hooks/useCompareHighlights.ts
+++ b/frontend/src/components/tools/compare/hooks/useCompareHighlights.ts
@@ -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;
@@ -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')) {
diff --git a/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts
index 288d057e3..39bf7bf59 100644
--- a/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts
+++ b/frontend/src/components/tools/compare/hooks/useComparePanZoom.ts
@@ -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(null);
const isSyncingRef = useRef(false);
const scrollLinkDeltaRef = useRef({ vertical: 0, horizontal: 0 });
+ const scrollLinkAnchorsRef = useRef({
+ deltaPixelsBaseToComp: 0,
+ deltaPixelsCompToBase: 0,
+ });
const [isScrollLinked, setIsScrollLinked] = useState(true);
const [isPanMode, setIsPanMode] = useState(false);
const panDragRef = useRef({
@@ -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 }), []);
diff --git a/frontend/src/tools/Compare.tsx b/frontend/src/tools/Compare.tsx
index bb0663c55..def35e21d 100644
--- a/frontend/src/tools/Compare.tsx
+++ b/frontend/src/tools/Compare.tsx
@@ -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) => {
-
- {stub?.name}
-
+
+
+
{meta && (
{meta}