mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
improvements to dropdowns and selection highlighting
This commit is contained in:
parent
e9a65a391e
commit
5a0d975baf
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 */
|
||||
|
||||
Loading…
Reference in New Issue
Block a user