diff --git a/frontend/src/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/components/tools/compare/CompareDocumentPane.tsx index 48e7c6615..848501950 100644 --- a/frontend/src/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/components/tools/compare/CompareDocumentPane.tsx @@ -40,30 +40,37 @@ interface CompareDocumentPaneProps { 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)); - +// Merge overlapping or touching rects into larger non-overlapping blocks. +// This is more robust across rotations (vertical "lines" etc.) and prevents dark spots. +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[] = []; - 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 }); + 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; + // Overlap or touch within EPS gap + 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; @@ -194,20 +201,24 @@ const CompareDocumentPane = ({ className="compare-diff-page__image" /> {[...groupedRects.entries()].flatMap(([id, rects]) => - mergeSameLineRects(rects).map((rect, index) => ( - - )) + mergeConnectedRects(rects).map((rect, index) => { + const rotation = ((page.rotation ?? 0) % 360 + 360) % 360; + const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset; + return ( + + ); + }) )} diff --git a/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx b/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx index 6d9934d77..7f91cd633 100644 --- a/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx +++ b/frontend/src/components/tools/compare/CompareNavigationDropdown.tsx @@ -1,4 +1,5 @@ import { Combobox, ScrollArea, useCombobox } from '@mantine/core'; +import { useMemo, useState } from 'react'; interface NavigationDropdownProps { changes: Array<{ value: string; label: string }>; @@ -17,6 +18,34 @@ const CompareNavigationDropdown = ({ onDropdownClose: () => combobox.resetSelectedOption(), }); + const sanitize = (s: string) => s + .normalize('NFKC') + .replace(/[\u0000-\u001F\u007F-\u00A0\u2000-\u206F\u2190-\u21FF\u25A0-\u25FF]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const isMeaningful = (s: string) => { + const t = sanitize(s); + // Keep only items that have at least one letter or digit (unicode-aware) + try { + if (!/[\p{L}\p{N}]/u.test(t)) return false; + } catch { + if (!/[A-Za-z0-9]/.test(t)) return false; + } + return t.length > 0; + }; + + const [query, setQuery] = useState(''); + + const normalizedChanges = useMemo(() => { + const cleaned = changes + .map((c) => ({ value: c.value, label: sanitize(c.label) })) + .filter((c) => isMeaningful(c.label)); + const q = sanitize(query).toLowerCase(); + if (!q) return cleaned; + return cleaned.filter((c) => c.label.toLowerCase().includes(q)); + }, [changes, query]); + return (
- + setQuery(e.currentTarget.value)} /> - {changes.length > 0 ? ( - changes.map((item) => ( + {normalizedChanges.length > 0 ? ( + normalizedChanges.map((item) => ( { if (token.type === 'removed') { const startIndex = baseIndex; const parts: string[] = []; + const runIndices: number[] = []; let pageNumber = result.tokenMetadata.base[baseIndex]?.page ?? 1; - // accumulate contiguous removed tokens + // accumulate contiguous removed tokens (record indices first) while (i < result.tokens.length && result.tokens[i].type === 'removed') { parts.push(result.tokens[i].text); - baseTokenIndexToGroupId.set(baseIndex, `base-group-${startIndex}-${baseIndex}`); + runIndices.push(baseIndex); baseIndex += 1; i += 1; } - // step back one because for-loop will ++ - i -= 1; + i -= 1; // step back because for-loop will ++ const endIndex = baseIndex - 1; + const groupId = `base-group-${startIndex}-${endIndex}`; + runIndices.forEach(idx => baseTokenIndexToGroupId.set(idx, groupId)); const label = parts.join(' ').trim(); - items.push({ value: `base-group-${startIndex}-${endIndex}`, label: label || '(…)', pageNumber }); + items.push({ value: groupId, label: label || '(…)', pageNumber }); continue; } if (token.type !== 'added') { @@ -1132,17 +1134,20 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { if (token.type === 'added') { const startIndex = comparisonIndex; const parts: string[] = []; + const runIndices: number[] = []; let pageNumber = result.tokenMetadata.comparison[comparisonIndex]?.page ?? 1; while (i < result.tokens.length && result.tokens[i].type === 'added') { parts.push(result.tokens[i].text); - comparisonTokenIndexToGroupId.set(comparisonIndex, `comparison-group-${startIndex}-${comparisonIndex}`); + runIndices.push(comparisonIndex); comparisonIndex += 1; i += 1; } i -= 1; const endIndex = comparisonIndex - 1; + const groupId = `comparison-group-${startIndex}-${endIndex}`; + runIndices.forEach(idx => comparisonTokenIndexToGroupId.set(idx, groupId)); const label = parts.join(' ').trim(); - items.push({ value: `comparison-group-${startIndex}-${endIndex}`, label: label || '(…)', pageNumber }); + items.push({ value: groupId, label: label || '(…)', pageNumber }); continue; } if (token.type !== 'removed') { @@ -1233,12 +1238,43 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => { container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'smooth' }); - // Retrigger flash for EVERY node in the group + // Create per-page overlays using percentage coordinates to cover the entire group + const groupsByInner = new Map(); + nodes.forEach((el) => { + const inner = el.closest('.compare-diff-page__inner') as HTMLElement | null; + if (!inner) return; + const arr = groupsByInner.get(inner) || []; + arr.push(el as HTMLElement); + groupsByInner.set(inner, arr); + }); + groupsByInner.forEach((els, inner) => { + let minL = 100, minT = 100, maxR = 0, maxB = 0; + els.forEach((el) => { + const l = parseFloat((el as HTMLElement).style.left) || 0; // % + const t = parseFloat((el as HTMLElement).style.top) || 0; // % + const w = parseFloat((el as HTMLElement).style.width) || 0; // % + const h = parseFloat((el as HTMLElement).style.height) || 0; // % + minL = Math.min(minL, l); + minT = Math.min(minT, t); + maxR = Math.max(maxR, l + w); + maxB = Math.max(maxB, t + h); + }); + 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); + }); + + // Also retrigger per-rect flash for accessibility (keeps ARIA relationships intact) nodes.forEach((el) => { el.classList.remove('compare-diff-highlight--flash'); }); - // Force reflow to restart animation - void container.clientWidth; + void container.clientWidth; // Force reflow to restart animation nodes.forEach((el) => { el.classList.add('compare-diff-highlight--flash'); window.setTimeout(() => el.classList.remove('compare-diff-highlight--flash'), 1600); diff --git a/frontend/src/components/tools/compare/compareView.css b/frontend/src/components/tools/compare/compareView.css index ef698e671..623141a82 100644 --- a/frontend/src/components/tools/compare/compareView.css +++ b/frontend/src/components/tools/compare/compareView.css @@ -236,6 +236,15 @@ 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; @@ -307,7 +316,7 @@ .compare-diff-highlight { position: absolute; pointer-events: none; - mix-blend-mode: multiply; + mix-blend-mode: normal; /* reduce dark spots on overlap */ } /* Inline paragraph highlights in summary */