improvements to dropdowns and selection highlighting

This commit is contained in:
EthanHealy01 2025-10-23 23:38:48 +01:00
parent e9a65a391e
commit 5a0d975baf
4 changed files with 137 additions and 51 deletions

View File

@ -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) => (
<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),
}}
/>
))
mergeConnectedRects(rects).map((rect, index) => {
const rotation = ((page.rotation ?? 0) % 360 + 360) % 360;
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: toRgba(highlightColor, highlightOpacity),
}}
/>
);
})
)}
</div>
</div>

View File

@ -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 (
<Combobox
store={combobox}
@ -25,6 +54,7 @@ const CompareNavigationDropdown = ({
onNavigate(value);
combobox.closeDropdown();
}}
// Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly
>
<Combobox.Target>
<div
@ -38,10 +68,10 @@ const CompareNavigationDropdown = ({
<Combobox.Dropdown>
<ScrollArea.Autosize mah={300}>
<Combobox.Search placeholder="Search changes..." />
<Combobox.Search placeholder="Search changes..." value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
<Combobox.Options>
{changes.length > 0 ? (
changes.map((item) => (
{normalizedChanges.length > 0 ? (
normalizedChanges.map((item) => (
<Combobox.Option
value={item.value}
key={item.value}

View File

@ -1099,19 +1099,21 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
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<HTMLElement, HTMLElement[]>();
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);

View File

@ -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 */