mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
almost PR ready, broke out components, remove unused, fixed bugs
This commit is contained in:
parent
5a0d975baf
commit
227a974a09
76
frontend/src/components/shared/LazyLoadContainer.tsx
Normal file
76
frontend/src/components/shared/LazyLoadContainer.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React, { useRef, useEffect, useState, ReactNode } from 'react';
|
||||
|
||||
interface LazyLoadContainerProps {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
rootMargin?: string;
|
||||
threshold?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onLoad?: () => void;
|
||||
onUnload?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable lazy loading container that only renders children when they come into view.
|
||||
* Uses Intersection Observer API for efficient viewport detection.
|
||||
*
|
||||
* @param children - Content to render when visible
|
||||
* @param fallback - Content to show while loading (optional)
|
||||
* @param rootMargin - Margin around root for intersection detection (default: "50px")
|
||||
* @param threshold - Intersection ratio threshold (default: 0.1)
|
||||
* @param className - CSS class name
|
||||
* @param style - Inline styles
|
||||
* @param onLoad - Callback when content becomes visible
|
||||
* @param onUnload - Callback when content becomes hidden
|
||||
*/
|
||||
export const LazyLoadContainer: React.FC<LazyLoadContainerProps> = ({
|
||||
children,
|
||||
fallback = null,
|
||||
rootMargin = "50px",
|
||||
threshold = 0.1,
|
||||
className,
|
||||
style,
|
||||
onLoad,
|
||||
onUnload,
|
||||
}) => {
|
||||
const [hasBeenVisible, setHasBeenVisible] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
const visible = entry.isIntersecting;
|
||||
|
||||
if (visible && !hasBeenVisible) {
|
||||
setHasBeenVisible(true);
|
||||
onLoad?.();
|
||||
} else if (!visible && hasBeenVisible) {
|
||||
onUnload?.();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [rootMargin, threshold, hasBeenVisible, onLoad, onUnload]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={className} style={style}>
|
||||
{hasBeenVisible ? children : fallback}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyLoadContainer;
|
||||
@ -1,4 +1,4 @@
|
||||
import { ToastOptions } from './types';
|
||||
import type { ToastApi, ToastInstance, ToastOptions } from './types';
|
||||
import { useToast, ToastProvider } from './ToastContext';
|
||||
import ToastRenderer from './ToastRenderer';
|
||||
|
||||
@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer };
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
type ToastContextApi = ToastApi & { toasts: ToastInstance[] };
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
const subscribers: Array<(fn: ToastContextApi) => void> = [];
|
||||
let api: ToastContextApi | null = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
provide(instance: ToastContextApi) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
subscribers.splice(0).forEach(cb => cb(instance));
|
||||
},
|
||||
get(): ToastContextApi | null {
|
||||
return api;
|
||||
},
|
||||
onReady(cb: (readyApi: ToastContextApi) => void) {
|
||||
if (api) {
|
||||
cb(api);
|
||||
} else {
|
||||
subscribers.push(cb);
|
||||
}
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -58,4 +66,3 @@ export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import { Alert, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { MutableRefObject } from 'react';
|
||||
import { RefObject } from 'react';
|
||||
import type { PagePreview, WordHighlightEntry } from './types';
|
||||
import type { TokenBoundingBox } from '../../../types/compare';
|
||||
import CompareNavigationDropdown from './CompareNavigationDropdown';
|
||||
import { toRgba } from './compareUtils';
|
||||
import LazyLoadContainer from '../../shared/LazyLoadContainer';
|
||||
|
||||
interface CompareDocumentPaneProps {
|
||||
pane: 'base' | 'comparison';
|
||||
scrollRef: MutableRefObject<HTMLDivElement | null>;
|
||||
peerScrollRef: MutableRefObject<HTMLDivElement | null>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
peerScrollRef: RefObject<HTMLDivElement | null>;
|
||||
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
|
||||
beginPan: (pane: 'base' | 'comparison', event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
continuePan: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
@ -22,17 +23,14 @@ interface CompareDocumentPaneProps {
|
||||
pan?: { x: number; y: number };
|
||||
title: string;
|
||||
dropdownPlaceholder?: string;
|
||||
changes: Array<{ value: string; label: string }>;
|
||||
onNavigateChange: (id: string) => void;
|
||||
changes: Array<{ value: string; label: string; pageNumber?: number }>;
|
||||
onNavigateChange: (id: string, pageNumber?: number) => void;
|
||||
isLoading: boolean;
|
||||
processingMessage: string;
|
||||
emptyMessage: string;
|
||||
pages: PagePreview[];
|
||||
pairedPages: PagePreview[];
|
||||
getRowHeightPx: (pageNumber: number) => number;
|
||||
highlightColor: string;
|
||||
highlightOpacity: number;
|
||||
offsetPixels: number;
|
||||
wordHighlightMap: Map<number, WordHighlightEntry[]>;
|
||||
tokenIndexToGroupId: Map<number, string>;
|
||||
documentLabel: string;
|
||||
@ -101,15 +99,16 @@ const CompareDocumentPane = ({
|
||||
pages,
|
||||
pairedPages,
|
||||
getRowHeightPx,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
offsetPixels,
|
||||
wordHighlightMap,
|
||||
tokenIndexToGroupId,
|
||||
documentLabel,
|
||||
pageLabel,
|
||||
altLabel,
|
||||
}: CompareDocumentPaneProps) => {
|
||||
// Constants that vary by pane
|
||||
const HIGHLIGHT_COLOR = pane === 'base' ? '#ff6b6b' : '#51cf66'; // red for base (removals), green for comparison (additions)
|
||||
const HIGHLIGHT_OPACITY = pane === 'base' ? 0.45 : 0.35;
|
||||
const OFFSET_PIXELS = pane === 'base' ? 4 : 2;
|
||||
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
|
||||
const panX = (pan?.x ?? 0);
|
||||
const panY = (pan?.y ?? 0);
|
||||
@ -165,7 +164,7 @@ const CompareDocumentPane = ({
|
||||
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
|
||||
const fit = targetHeight / page.height;
|
||||
const rowHeightPx = getRowHeightPx(page.pageNumber);
|
||||
const highlightOffset = offsetPixels / page.height;
|
||||
const highlightOffset = OFFSET_PIXELS / page.height;
|
||||
|
||||
const wordRects = wordHighlightMap.get(page.pageNumber) ?? [];
|
||||
const groupedRects = new Map<string, TokenBoundingBox[]>();
|
||||
@ -178,51 +177,91 @@ const CompareDocumentPane = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<LazyLoadContainer
|
||||
key={`${pane}-page-${page.pageNumber}`}
|
||||
className="compare-diff-page"
|
||||
style={{ minHeight: `${rowHeightPx}px` }}
|
||||
>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{documentLabel} · {pageLabel} {page.pageNumber}
|
||||
</Text>
|
||||
<div
|
||||
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
|
||||
style={{ width: `${Math.round(page.width * fit)}px` }}
|
||||
>
|
||||
rootMargin="100px"
|
||||
threshold={0.1}
|
||||
fallback={
|
||||
<div
|
||||
className="compare-diff-page__inner"
|
||||
style={{ transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
className="compare-diff-page"
|
||||
data-page-number={page.pageNumber}
|
||||
style={{ minHeight: `${rowHeightPx}px` }}
|
||||
>
|
||||
<img
|
||||
src={page.url}
|
||||
alt={altLabel}
|
||||
loading="lazy"
|
||||
className="compare-diff-page__image"
|
||||
/>
|
||||
{[...groupedRects.entries()].flatMap(([id, rects]) =>
|
||||
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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{documentLabel} · {pageLabel} {page.pageNumber}
|
||||
</Text>
|
||||
<div
|
||||
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
|
||||
style={{ width: `${Math.round(page.width * fit)}px` }}
|
||||
>
|
||||
<div
|
||||
className="compare-diff-page__inner"
|
||||
style={{ transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${Math.round(page.height * fit)}px`,
|
||||
backgroundColor: '#f8f9fa',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid #e9ecef',
|
||||
}}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="compare-diff-page"
|
||||
data-page-number={page.pageNumber}
|
||||
style={{ minHeight: `${rowHeightPx}px` }}
|
||||
>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{documentLabel} · {pageLabel} {page.pageNumber}
|
||||
</Text>
|
||||
<div
|
||||
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
|
||||
style={{ width: `${Math.round(page.width * fit)}px` }}
|
||||
>
|
||||
<div
|
||||
className="compare-diff-page__inner"
|
||||
style={{ transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
<img
|
||||
src={page.url}
|
||||
alt={altLabel}
|
||||
loading="lazy"
|
||||
className="compare-diff-page__image"
|
||||
/>
|
||||
{[...groupedRects.entries()].flatMap(([id, rects]) =>
|
||||
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(HIGHLIGHT_COLOR, HIGHLIGHT_OPACITY),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LazyLoadContainer>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
@ -2,10 +2,10 @@ import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface NavigationDropdownProps {
|
||||
changes: Array<{ value: string; label: string }>;
|
||||
changes: Array<{ value: string; label: string; pageNumber?: number }>;
|
||||
placeholder: string;
|
||||
className?: string;
|
||||
onNavigate: (value: string) => void;
|
||||
onNavigate: (value: string, pageNumber?: number) => void;
|
||||
}
|
||||
|
||||
const CompareNavigationDropdown = ({
|
||||
@ -18,11 +18,23 @@ 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 sanitize = (s: string) => {
|
||||
// Normalize and remove control/separator characters without regex ranges
|
||||
return s
|
||||
.normalize('NFKC')
|
||||
.split('')
|
||||
.map(char => {
|
||||
const code = char.charCodeAt(0);
|
||||
// Replace control chars (0-31, 127) and special separators with space
|
||||
if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) {
|
||||
return ' ';
|
||||
}
|
||||
return char;
|
||||
})
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const isMeaningful = (s: string) => {
|
||||
const t = sanitize(s);
|
||||
@ -39,7 +51,7 @@ const CompareNavigationDropdown = ({
|
||||
|
||||
const normalizedChanges = useMemo(() => {
|
||||
const cleaned = changes
|
||||
.map((c) => ({ value: c.value, label: sanitize(c.label) }))
|
||||
.map((c) => ({ value: c.value, label: sanitize(c.label), pageNumber: c.pageNumber }))
|
||||
.filter((c) => isMeaningful(c.label));
|
||||
const q = sanitize(query).toLowerCase();
|
||||
if (!q) return cleaned;
|
||||
@ -51,7 +63,8 @@ const CompareNavigationDropdown = ({
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => {
|
||||
onNavigate(value);
|
||||
const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber;
|
||||
onNavigate(value, pn);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
// Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly
|
||||
@ -76,7 +89,7 @@ const CompareNavigationDropdown = ({
|
||||
value={item.value}
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
onNavigate(item.value);
|
||||
onNavigate(item.value, item.pageNumber);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
|
||||
147
frontend/src/components/tools/compare/CompareUploadSection.tsx
Normal file
147
frontend/src/components/tools/compare/CompareUploadSection.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Button, Stack, Text } from '@mantine/core';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { formatFileSize } from '../../../utils/fileUtils';
|
||||
import type { StirlingFileStub } from '../../../types/fileContext';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface UploadColumnProps {
|
||||
role: 'base' | 'comparison';
|
||||
file: File | null;
|
||||
stub: StirlingFileStub | null;
|
||||
title: string;
|
||||
description: string;
|
||||
accentClass: string;
|
||||
disabled: boolean;
|
||||
onDrop: (files: File[]) => void;
|
||||
onSelectExisting: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
interface CompareUploadSectionProps {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
disabled: boolean;
|
||||
base: UploadColumnProps;
|
||||
comparison: UploadColumnProps;
|
||||
}
|
||||
|
||||
const CompareUploadColumn = ({
|
||||
role,
|
||||
file,
|
||||
stub,
|
||||
title,
|
||||
description,
|
||||
accentClass,
|
||||
disabled,
|
||||
onDrop,
|
||||
onSelectExisting,
|
||||
onClear,
|
||||
}: UploadColumnProps) => {
|
||||
const { t } = useTranslation();
|
||||
const openRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const fileLabel = useMemo(() => {
|
||||
const fileName = stub?.name ?? file?.name ?? null;
|
||||
const fileSize = stub?.size ?? file?.size ?? null;
|
||||
if (!fileName) {
|
||||
return null;
|
||||
}
|
||||
return fileSize ? `${fileName} • ${formatFileSize(fileSize)}` : fileName;
|
||||
}, [file, stub]);
|
||||
|
||||
return (
|
||||
<div className="compare-upload-column" key={`upload-column-${role}`}>
|
||||
<Dropzone
|
||||
openRef={((instance: (() => void | undefined) | null) => {
|
||||
openRef.current = instance ?? null;
|
||||
}) as ForwardedRef<() => void | undefined>}
|
||||
onDrop={onDrop}
|
||||
disabled={disabled}
|
||||
multiple
|
||||
className="compare-upload-dropzone"
|
||||
>
|
||||
<div className="compare-upload-card">
|
||||
<div className={`compare-upload-icon ${accentClass}`}>
|
||||
<LocalIcon icon="upload" width="2.5rem" height="2.5rem" />
|
||||
</div>
|
||||
<Text fw={600} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
<div className="compare-upload-actions">
|
||||
<Button
|
||||
onClick={() => openRef.current?.()}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('compare.upload.browse', 'Browse files')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onSelectExisting}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('compare.upload.selectExisting', 'Select existing')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fileLabel ? (
|
||||
<div className="compare-upload-selection">
|
||||
<Text size="sm" fw={500} lineClamp={2}>
|
||||
{fileLabel}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClear}
|
||||
disabled={disabled}
|
||||
size="xs"
|
||||
>
|
||||
{t('compare.upload.clearSelection', 'Clear selection')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t('compare.upload.instructions', 'Drag & drop here or use the buttons to choose a file.')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Dropzone>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CompareUploadSection = ({
|
||||
heading,
|
||||
subheading,
|
||||
disabled,
|
||||
base,
|
||||
comparison,
|
||||
}: CompareUploadSectionProps) => {
|
||||
return (
|
||||
<Stack className="compare-workbench compare-workbench--upload" gap="lg">
|
||||
<Stack gap={4} align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{heading}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={520}>
|
||||
{subheading}
|
||||
</Text>
|
||||
</Stack>
|
||||
<div className="compare-upload-layout">
|
||||
<CompareUploadColumn {...base} disabled={disabled} />
|
||||
<div className="compare-upload-divider" aria-hidden="true" />
|
||||
<CompareUploadColumn {...comparison} disabled={disabled} />
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareUploadSection;
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,126 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
export const useCompareChangeNavigation = (
|
||||
baseScrollRef: RefObject<HTMLDivElement | null>,
|
||||
comparisonScrollRef: RefObject<HTMLDivElement | null>
|
||||
) => {
|
||||
return useCallback(
|
||||
(changeValue: string, pane: Pane, pageNumber?: number) => {
|
||||
const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef;
|
||||
const container = targetRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const findNodes = (): HTMLElement[] => {
|
||||
return Array.from(
|
||||
container.querySelectorAll(`[data-change-id="${changeValue}"]`)
|
||||
) as HTMLElement[];
|
||||
};
|
||||
|
||||
const scrollToPageIfNeeded = () => {
|
||||
if (!pageNumber) return false;
|
||||
const pageEl = container.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!pageEl) return false;
|
||||
const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2);
|
||||
container.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
|
||||
return true;
|
||||
};
|
||||
|
||||
let nodes = findNodes();
|
||||
if (nodes.length === 0) {
|
||||
scrollToPageIfNeeded();
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const ensureAndScroll = () => {
|
||||
nodes = findNodes();
|
||||
if (nodes.length === 0 && attempts < 12) {
|
||||
attempts += 1;
|
||||
scrollToPageIfNeeded();
|
||||
window.requestAnimationFrame(ensureAndScroll);
|
||||
return;
|
||||
}
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
let minTop = Number.POSITIVE_INFINITY;
|
||||
let minLeft = Number.POSITIVE_INFINITY;
|
||||
let maxBottom = Number.NEGATIVE_INFINITY;
|
||||
let maxRight = Number.NEGATIVE_INFINITY;
|
||||
|
||||
nodes.forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
minTop = Math.min(minTop, rect.top);
|
||||
minLeft = Math.min(minLeft, rect.left);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
});
|
||||
|
||||
const boxHeight = Math.max(1, maxBottom - minTop);
|
||||
const boxWidth = Math.max(1, maxRight - minLeft);
|
||||
const absoluteTop = minTop - containerRect.top + container.scrollTop;
|
||||
const absoluteLeft = minLeft - containerRect.left + container.scrollLeft;
|
||||
const desiredTop = Math.max(0, absoluteTop - (container.clientHeight - boxHeight) / 2);
|
||||
const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2);
|
||||
|
||||
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'smooth' });
|
||||
|
||||
const groupsByInner = new Map<HTMLElement, HTMLElement[]>();
|
||||
nodes.forEach((element) => {
|
||||
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||
if (!inner) return;
|
||||
const list = groupsByInner.get(inner) ?? [];
|
||||
list.push(element);
|
||||
groupsByInner.set(inner, list);
|
||||
});
|
||||
|
||||
groupsByInner.forEach((elements, inner) => {
|
||||
let minL = 100;
|
||||
let minT = 100;
|
||||
let maxR = 0;
|
||||
let maxB = 0;
|
||||
elements.forEach((element) => {
|
||||
const leftPercent = parseFloat(element.style.left) || 0;
|
||||
const topPercent = parseFloat(element.style.top) || 0;
|
||||
const widthPercent = parseFloat(element.style.width) || 0;
|
||||
const heightPercent = parseFloat(element.style.height) || 0;
|
||||
minL = Math.min(minL, leftPercent);
|
||||
minT = Math.min(minT, topPercent);
|
||||
maxR = Math.max(maxR, leftPercent + widthPercent);
|
||||
maxB = Math.max(maxB, topPercent + heightPercent);
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
nodes.forEach((element) => {
|
||||
element.classList.remove('compare-diff-highlight--flash');
|
||||
});
|
||||
void container.clientWidth; // Force reflow
|
||||
nodes.forEach((element) => {
|
||||
element.classList.add('compare-diff-highlight--flash');
|
||||
window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600);
|
||||
});
|
||||
};
|
||||
|
||||
ensureAndScroll();
|
||||
},
|
||||
[baseScrollRef, comparisonScrollRef]
|
||||
);
|
||||
};
|
||||
|
||||
export type UseCompareChangeNavigationReturn = ReturnType<typeof useCompareChangeNavigation>;
|
||||
@ -0,0 +1,162 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
CompareDiffToken,
|
||||
CompareResultData,
|
||||
CompareTokenMetadata,
|
||||
} from '../../../../types/compare';
|
||||
import type { CompareChangeOption } from '../../../../types/compareWorkbench';
|
||||
import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews';
|
||||
import type { WordHighlightEntry } from '../types';
|
||||
|
||||
interface TokenGroupMap {
|
||||
base: Map<number, string>;
|
||||
comparison: Map<number, string>;
|
||||
}
|
||||
|
||||
interface WordHighlightMaps {
|
||||
base: Map<number, WordHighlightEntry[]>;
|
||||
comparison: Map<number, WordHighlightEntry[]>;
|
||||
}
|
||||
|
||||
export interface UseCompareHighlightsResult {
|
||||
baseWordChanges: CompareChangeOption[];
|
||||
comparisonWordChanges: CompareChangeOption[];
|
||||
tokenIndexToGroupId: TokenGroupMap;
|
||||
wordHighlightMaps: WordHighlightMaps;
|
||||
getRowHeightPx: (pageNumber: number) => number;
|
||||
}
|
||||
|
||||
const buildWordChanges = (
|
||||
tokens: CompareDiffToken[],
|
||||
metadata: CompareTokenMetadata[],
|
||||
targetType: 'added' | 'removed',
|
||||
tokenIndexToGroupId: Map<number, string>,
|
||||
groupPrefix: string
|
||||
): CompareChangeOption[] => {
|
||||
tokenIndexToGroupId.clear();
|
||||
if (!tokens.length) return [];
|
||||
|
||||
const items: CompareChangeOption[] = [];
|
||||
let metadataIndex = 0;
|
||||
|
||||
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);
|
||||
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 });
|
||||
continue;
|
||||
}
|
||||
if (token.type !== (targetType === 'added' ? 'removed' : 'added')) {
|
||||
metadataIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
export const useCompareHighlights = (
|
||||
result: CompareResultData | null,
|
||||
basePages: PagePreview[],
|
||||
comparisonPages: PagePreview[],
|
||||
): UseCompareHighlightsResult => {
|
||||
const baseTokenIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
const comparisonTokenIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
|
||||
const baseWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.tokens,
|
||||
result.tokenMetadata.base,
|
||||
'removed',
|
||||
baseTokenIndexToGroupId,
|
||||
'base-group'
|
||||
);
|
||||
}, [baseTokenIndexToGroupId, result]);
|
||||
|
||||
const comparisonWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.tokens,
|
||||
result.tokenMetadata.comparison,
|
||||
'added',
|
||||
comparisonTokenIndexToGroupId,
|
||||
'comparison-group'
|
||||
);
|
||||
}, [comparisonTokenIndexToGroupId, result]);
|
||||
|
||||
const wordHighlightMaps = useMemo(() => {
|
||||
if (!result) {
|
||||
return {
|
||||
base: new Map<number, WordHighlightEntry[]>(),
|
||||
comparison: new Map<number, WordHighlightEntry[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
const baseMap = new Map<number, WordHighlightEntry[]>();
|
||||
const comparisonMap = new Map<number, WordHighlightEntry[]>();
|
||||
|
||||
let baseIndex = 0;
|
||||
let comparisonIndex = 0;
|
||||
for (const token of result.tokens) {
|
||||
if (token.type === 'removed') {
|
||||
const meta = result.tokenMetadata.base[baseIndex];
|
||||
if (meta?.bbox) {
|
||||
const list = baseMap.get(meta.page) ?? [];
|
||||
list.push({ rect: meta.bbox, index: baseIndex });
|
||||
baseMap.set(meta.page, list);
|
||||
}
|
||||
baseIndex += 1;
|
||||
} else if (token.type === 'added') {
|
||||
const meta = result.tokenMetadata.comparison[comparisonIndex];
|
||||
if (meta?.bbox) {
|
||||
const list = comparisonMap.get(meta.page) ?? [];
|
||||
list.push({ rect: meta.bbox, index: comparisonIndex });
|
||||
comparisonMap.set(meta.page, list);
|
||||
}
|
||||
comparisonIndex += 1;
|
||||
} else {
|
||||
baseIndex += 1;
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { base: baseMap, comparison: comparisonMap };
|
||||
}, [result]);
|
||||
|
||||
const getRowHeightPx = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const basePage = basePages.find((page) => page.pageNumber === pageNumber);
|
||||
const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber);
|
||||
const baseHeight = basePage ? basePage.height : 0;
|
||||
const comparisonHeight = comparisonPage ? comparisonPage.height : 0;
|
||||
const rowHeight = Math.max(baseHeight, comparisonHeight);
|
||||
return Math.round(rowHeight);
|
||||
},
|
||||
[basePages, comparisonPages]
|
||||
);
|
||||
|
||||
return {
|
||||
baseWordChanges,
|
||||
comparisonWordChanges,
|
||||
tokenIndexToGroupId: {
|
||||
base: baseTokenIndexToGroupId,
|
||||
comparison: comparisonTokenIndexToGroupId,
|
||||
},
|
||||
wordHighlightMaps,
|
||||
getRowHeightPx,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pdfWorkerManager } from '../../../../services/pdfWorkerManager';
|
||||
import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews';
|
||||
|
||||
const DISPLAY_SCALE = 1;
|
||||
|
||||
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||
|
||||
const renderPdfDocumentToImages = async (file: File): Promise<PagePreview[]> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const previews: PagePreview[] = [];
|
||||
const dpr = getDevicePixelRatio();
|
||||
const renderScale = Math.max(2, Math.min(3, dpr * 2));
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
|
||||
const renderViewport = page.getViewport({ scale: renderScale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
canvas.width = Math.round(renderViewport.width);
|
||||
canvas.height = Math.round(renderViewport.height);
|
||||
|
||||
if (!context) {
|
||||
page.cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||
previews.push({
|
||||
pageNumber,
|
||||
width: Math.round(displayViewport.width),
|
||||
height: Math.round(displayViewport.height),
|
||||
rotation: (page.rotate || 0) % 360,
|
||||
url: canvas.toDataURL(),
|
||||
});
|
||||
|
||||
page.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
return previews;
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
}
|
||||
};
|
||||
|
||||
interface UseComparePagePreviewsOptions {
|
||||
file: File | null;
|
||||
enabled: boolean;
|
||||
cacheKey: number | null;
|
||||
}
|
||||
|
||||
export const useComparePagePreviews = ({
|
||||
file,
|
||||
enabled,
|
||||
cacheKey,
|
||||
}: UseComparePagePreviewsOptions) => {
|
||||
const [pages, setPages] = useState<PagePreview[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!file || !enabled) {
|
||||
setPages([]);
|
||||
setLoading(false);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const render = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const previews = await renderPdfDocumentToImages(file);
|
||||
if (!cancelled) {
|
||||
setPages(previews);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[compare] failed to render document preview', error);
|
||||
if (!cancelled) {
|
||||
setPages([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file, enabled, cacheKey]);
|
||||
|
||||
return { pages, loading };
|
||||
};
|
||||
|
||||
export type UseComparePagePreviewsReturn = ReturnType<typeof useComparePagePreviews>;
|
||||
680
frontend/src/components/tools/compare/hooks/useComparePanZoom.ts
Normal file
680
frontend/src/components/tools/compare/hooks/useComparePanZoom.ts
Normal file
@ -0,0 +1,680 @@
|
||||
import {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
import type { PagePreview } from '../../../../hooks/useProgressivePagePreviews';
|
||||
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 100000;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
interface PanState {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ScrollLinkDelta {
|
||||
vertical: number;
|
||||
horizontal: number;
|
||||
}
|
||||
|
||||
interface PanDragState {
|
||||
active: boolean;
|
||||
source: Pane | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startPanX: number;
|
||||
startPanY: number;
|
||||
targetStartPanX: number;
|
||||
targetStartPanY: number;
|
||||
}
|
||||
|
||||
interface PinchState {
|
||||
active: boolean;
|
||||
pane: Pane | null;
|
||||
startDistance: number;
|
||||
startZoom: number;
|
||||
}
|
||||
|
||||
export interface UseComparePanZoomOptions {
|
||||
prefersStacked: boolean;
|
||||
basePages: PagePreview[];
|
||||
comparisonPages: PagePreview[];
|
||||
}
|
||||
|
||||
export interface UseComparePanZoomReturn {
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
setLayout: (layout: 'side-by-side' | 'stacked') => void;
|
||||
toggleLayout: () => void;
|
||||
baseScrollRef: RefObject<HTMLDivElement | null>;
|
||||
comparisonScrollRef: RefObject<HTMLDivElement | null>;
|
||||
isScrollLinked: boolean;
|
||||
setIsScrollLinked: (value: boolean) => void;
|
||||
captureScrollLinkDelta: () => void;
|
||||
clearScrollLinkDelta: () => void;
|
||||
isPanMode: boolean;
|
||||
setIsPanMode: (value: boolean) => void;
|
||||
baseZoom: number;
|
||||
setBaseZoom: (value: number) => void;
|
||||
comparisonZoom: number;
|
||||
setComparisonZoom: (value: number) => void;
|
||||
basePan: PanState;
|
||||
comparisonPan: PanState;
|
||||
centerPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
clampPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
|
||||
beginPan: (pane: Pane, event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
continuePan: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||||
endPan: () => void;
|
||||
handleWheelZoom: (pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => void;
|
||||
onTouchStart: (pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => void;
|
||||
onTouchMove: (event: ReactTouchEvent<HTMLDivElement>) => void;
|
||||
onTouchEnd: () => void;
|
||||
zoomLimits: { min: number; max: number; step: number };
|
||||
}
|
||||
|
||||
export const useComparePanZoom = ({
|
||||
basePages,
|
||||
comparisonPages,
|
||||
prefersStacked,
|
||||
}: UseComparePanZoomOptions): UseComparePanZoomReturn => {
|
||||
const baseScrollRef = useRef<HTMLDivElement>(null);
|
||||
const comparisonScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isSyncingRef = useRef(false);
|
||||
const scrollLinkDeltaRef = useRef<ScrollLinkDelta>({ vertical: 0, horizontal: 0 });
|
||||
const [isScrollLinked, setIsScrollLinked] = useState(true);
|
||||
const [isPanMode, setIsPanMode] = useState(false);
|
||||
const panDragRef = useRef<PanDragState>({
|
||||
active: false,
|
||||
source: null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startPanX: 0,
|
||||
startPanY: 0,
|
||||
targetStartPanX: 0,
|
||||
targetStartPanY: 0,
|
||||
});
|
||||
const lastActivePaneRef = useRef<Pane>('base');
|
||||
const [baseZoom, setBaseZoom] = useState(1);
|
||||
const [comparisonZoom, setComparisonZoom] = useState(1);
|
||||
const [basePan, setBasePan] = useState<PanState>({ x: 0, y: 0 });
|
||||
const [comparisonPan, setComparisonPan] = useState<PanState>({ x: 0, y: 0 });
|
||||
const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||
const pinchRef = useRef<PinchState>({ active: false, pane: null, startDistance: 0, startZoom: 1 });
|
||||
|
||||
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
|
||||
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
|
||||
setLayoutState(next);
|
||||
}, []);
|
||||
const toggleLayout = useCallback(() => {
|
||||
setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev));
|
||||
}, [prefersStacked]);
|
||||
|
||||
const getPagesForPane = useCallback(
|
||||
(pane: Pane) => (pane === 'base' ? basePages : comparisonPages),
|
||||
[basePages, comparisonPages]
|
||||
);
|
||||
|
||||
// Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height
|
||||
const rowHeights = useMemo(() => {
|
||||
const allPageNumbers = Array.from(
|
||||
new Set([
|
||||
...basePages.map(p => p.pageNumber),
|
||||
...comparisonPages.map(p => p.pageNumber),
|
||||
])
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
const base: number[] = [];
|
||||
const comp: number[] = [];
|
||||
for (const pageNumber of allPageNumbers) {
|
||||
const b = basePages.find(p => p.pageNumber === pageNumber) || null;
|
||||
const c = comparisonPages.find(p => p.pageNumber === pageNumber) || null;
|
||||
const h = Math.round(Math.max(b?.height ?? 0, c?.height ?? 0));
|
||||
if (b) base.push(h);
|
||||
if (c) comp.push(h);
|
||||
if (!b && c) {
|
||||
// base missing this page; still push height for mapping purposes
|
||||
base.push(h);
|
||||
}
|
||||
if (!c && b) {
|
||||
// comparison missing this page; still push height for mapping purposes
|
||||
comp.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = (arr: number[]) => {
|
||||
const out: number[] = new Array(arr.length + 1);
|
||||
out[0] = 0;
|
||||
for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i];
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
base,
|
||||
comp,
|
||||
basePrefix: prefix(base),
|
||||
compPrefix: prefix(comp),
|
||||
};
|
||||
}, [basePages, comparisonPages]);
|
||||
|
||||
const mapScrollTopBetweenPanes = useCallback(
|
||||
(sourceTop: number, sourceIsBase: boolean): number => {
|
||||
const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp;
|
||||
const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base;
|
||||
const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix;
|
||||
const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix;
|
||||
|
||||
if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop;
|
||||
|
||||
// Clamp to valid range
|
||||
const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1);
|
||||
const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop)));
|
||||
|
||||
// Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1]
|
||||
let lo = 0;
|
||||
let hi = srcHeights.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2);
|
||||
if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
const i = lo;
|
||||
const within = top - srcPrefix[i];
|
||||
const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0;
|
||||
|
||||
const j = Math.min(i, dstHeights.length - 1);
|
||||
const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1);
|
||||
return dstTop;
|
||||
},
|
||||
[rowHeights]
|
||||
);
|
||||
|
||||
const getMaxCanvasSize = useCallback(
|
||||
(pane: Pane) => {
|
||||
const pages = getPagesForPane(pane);
|
||||
const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base');
|
||||
let maxW = 0;
|
||||
let maxH = 0;
|
||||
for (const page of pages) {
|
||||
const peer = peers.find(p => p.pageNumber === page.pageNumber);
|
||||
const targetHeight = peer ? Math.max(page.height, peer.height) : page.height;
|
||||
const fit = targetHeight / page.height;
|
||||
const width = Math.round(page.width * fit);
|
||||
const height = Math.round(targetHeight);
|
||||
if (width > maxW) maxW = width;
|
||||
if (height > maxH) maxH = height;
|
||||
}
|
||||
return { maxW, maxH };
|
||||
},
|
||||
[getPagesForPane]
|
||||
);
|
||||
|
||||
const getPanBounds = useCallback(
|
||||
(pane: Pane, zoomOverride?: number) => {
|
||||
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null;
|
||||
let canvasW: number | null = null;
|
||||
let canvasH: number | null = null;
|
||||
if (canvasEl) {
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
canvasW = Math.max(0, Math.round(rect.width));
|
||||
canvasH = Math.max(0, Math.round(rect.height));
|
||||
}
|
||||
|
||||
const fallback = getMaxCanvasSize(pane);
|
||||
const W = canvasW ?? fallback.maxW;
|
||||
const H = canvasH ?? fallback.maxH;
|
||||
const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom;
|
||||
const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1));
|
||||
const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1));
|
||||
return { maxX: extraX, maxY: extraY };
|
||||
},
|
||||
[baseZoom, comparisonZoom, getMaxCanvasSize]
|
||||
);
|
||||
|
||||
const getPaneRotation = useCallback(
|
||||
(pane: Pane) => {
|
||||
const pages = getPagesForPane(pane);
|
||||
const rotation = pages[0]?.rotation ?? 0;
|
||||
const normalized = ((rotation % 360) + 360) % 360;
|
||||
return normalized as 0 | 90 | 180 | 270 | number;
|
||||
},
|
||||
[getPagesForPane]
|
||||
);
|
||||
|
||||
const mapPanBetweenOrientations = useCallback(
|
||||
(source: Pane, target: Pane, sourcePan: PanState) => {
|
||||
const sourceRotation = getPaneRotation(source);
|
||||
const targetRotation = getPaneRotation(target);
|
||||
const sourceBounds = getPanBounds(source);
|
||||
const targetBounds = getPanBounds(target);
|
||||
|
||||
const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1;
|
||||
const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1;
|
||||
|
||||
const applyRotation = (nx: number, ny: number, rotation: number) => {
|
||||
const r = ((rotation % 360) + 360) % 360;
|
||||
if (r === 0) return { nx, ny };
|
||||
if (r === 90) return { nx: ny, ny: -nx };
|
||||
if (r === 180) return { nx: -nx, ny: -ny };
|
||||
if (r === 270) return { nx: -ny, ny: nx };
|
||||
return { nx, ny };
|
||||
};
|
||||
|
||||
const logical = applyRotation(sx, sy, sourceRotation);
|
||||
const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation);
|
||||
|
||||
const targetNormX = (targetCentered.nx + 1) / 2;
|
||||
const targetNormY = (targetCentered.ny + 1) / 2;
|
||||
|
||||
const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX));
|
||||
const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY));
|
||||
return { x: targetX, y: targetY };
|
||||
},
|
||||
[getPaneRotation, getPanBounds]
|
||||
);
|
||||
|
||||
const centerPanForZoom = useCallback(
|
||||
(pane: Pane, zoomValue: number) => {
|
||||
const bounds = getPanBounds(pane, zoomValue);
|
||||
const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) };
|
||||
if (pane === 'base') {
|
||||
setBasePan(center);
|
||||
} else {
|
||||
setComparisonPan(center);
|
||||
}
|
||||
},
|
||||
[getPanBounds]
|
||||
);
|
||||
|
||||
const clampPanForZoom = useCallback(
|
||||
(pane: Pane, zoomValue: number) => {
|
||||
const bounds = getPanBounds(pane, zoomValue);
|
||||
const current = pane === 'base' ? basePan : comparisonPan;
|
||||
const clamped = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, current.x)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, current.y)),
|
||||
};
|
||||
if (pane === 'base') {
|
||||
setBasePan(clamped);
|
||||
} else {
|
||||
setComparisonPan(clamped);
|
||||
}
|
||||
},
|
||||
[basePan, comparisonPan, getPanBounds]
|
||||
);
|
||||
|
||||
const handleScrollSync = useCallback(
|
||||
(source: HTMLDivElement | null, target: HTMLDivElement | null) => {
|
||||
if (panDragRef.current.active) return;
|
||||
if (!source || !target || isSyncingRef.current || !isScrollLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
isSyncingRef.current = true;
|
||||
target.scrollTop = desiredTop;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
},
|
||||
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||
);
|
||||
|
||||
const beginPan = useCallback(
|
||||
(pane: Pane, event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isPanMode) return;
|
||||
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
if (zoom <= 1) return;
|
||||
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const targetEl = event.target as HTMLElement | null;
|
||||
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||
if (!isOnImage) return;
|
||||
|
||||
event.preventDefault();
|
||||
panDragRef.current = {
|
||||
active: true,
|
||||
source: pane,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||
};
|
||||
lastActivePaneRef.current = pane;
|
||||
(container as HTMLDivElement).style.cursor = 'grabbing';
|
||||
},
|
||||
[isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan]
|
||||
);
|
||||
|
||||
const continuePan = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isPanMode) return;
|
||||
const drag = panDragRef.current;
|
||||
if (!drag.active || !drag.source) return;
|
||||
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
|
||||
const isBase = drag.source === 'base';
|
||||
const bounds = getPanBounds(drag.source);
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, drag.startPanX - dx)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, drag.startPanY - dy)),
|
||||
};
|
||||
|
||||
if (isScrollLinked) {
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
const otherPane: Pane = isBase ? 'comparison' : 'base';
|
||||
const mappedPeer = mapPanBetweenOrientations(drag.source, otherPane, desired);
|
||||
const peerBounds = getPanBounds(otherPane);
|
||||
const clampedPeer = {
|
||||
x: Math.max(0, Math.min(peerBounds.maxX, mappedPeer.x)),
|
||||
y: Math.max(0, Math.min(peerBounds.maxY, mappedPeer.y)),
|
||||
};
|
||||
if (isBase) {
|
||||
setComparisonPan(clampedPeer);
|
||||
} else {
|
||||
setBasePan(clampedPeer);
|
||||
}
|
||||
} else {
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
}
|
||||
},
|
||||
[getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations]
|
||||
);
|
||||
|
||||
const endPan = useCallback(() => {
|
||||
const drag = panDragRef.current;
|
||||
if (!drag.active) return;
|
||||
const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (sourceEl) {
|
||||
const zoom = drag.source === 'base' ? baseZoom : comparisonZoom;
|
||||
(sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : '';
|
||||
}
|
||||
panDragRef.current.active = false;
|
||||
panDragRef.current.source = null;
|
||||
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||
|
||||
const handleWheelZoom = useCallback(
|
||||
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (!event.ctrlKey) return;
|
||||
event.preventDefault();
|
||||
const key = pane === 'base' ? 'base' : 'comparison';
|
||||
const accum = wheelZoomAccumRef.current;
|
||||
const threshold = 180;
|
||||
accum[key] += event.deltaY;
|
||||
const steps = Math.trunc(Math.abs(accum[key]) / threshold);
|
||||
if (steps <= 0) return;
|
||||
const direction = accum[key] > 0 ? -1 : 1;
|
||||
accum[key] = accum[key] % threshold;
|
||||
const applySteps = (zoom: number) => {
|
||||
let next = zoom;
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
next = direction > 0
|
||||
? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2))
|
||||
: Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2));
|
||||
}
|
||||
return next;
|
||||
};
|
||||
if (pane === 'base') {
|
||||
const prev = baseZoom;
|
||||
const next = applySteps(prev);
|
||||
setBaseZoom(next);
|
||||
if (next < prev) {
|
||||
centerPanForZoom('base', next);
|
||||
} else {
|
||||
clampPanForZoom('base', next);
|
||||
}
|
||||
} else {
|
||||
const prev = comparisonZoom;
|
||||
const next = applySteps(prev);
|
||||
setComparisonZoom(next);
|
||||
if (next < prev) {
|
||||
centerPanForZoom('comparison', next);
|
||||
} else {
|
||||
clampPanForZoom('comparison', next);
|
||||
}
|
||||
}
|
||||
},
|
||||
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom]
|
||||
);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (event.touches.length === 2) {
|
||||
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
const dy = t1.clientY - t2.clientY;
|
||||
pinchRef.current = {
|
||||
active: true,
|
||||
pane,
|
||||
startDistance: Math.hypot(dx, dy),
|
||||
startZoom: pane === 'base' ? baseZoom : comparisonZoom,
|
||||
};
|
||||
event.preventDefault();
|
||||
} else if (event.touches.length === 1) {
|
||||
if (!isPanMode) return;
|
||||
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
if (zoom <= 1) return;
|
||||
const targetEl = event.target as HTMLElement | null;
|
||||
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||
if (!isOnImage) return;
|
||||
const touch = event.touches[0];
|
||||
panDragRef.current = {
|
||||
active: true,
|
||||
source: pane,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||
};
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode]
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (pinchRef.current.active && event.touches.length === 2) {
|
||||
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
const dy = t1.clientY - t2.clientY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const scale = distance / Math.max(1, pinchRef.current.startDistance);
|
||||
const dampened = 1 + (scale - 1) * 0.6;
|
||||
const pane = pinchRef.current.pane!;
|
||||
const startZoom = pinchRef.current.startZoom;
|
||||
const previousZoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
|
||||
if (pane === 'base') {
|
||||
setBaseZoom(nextZoom);
|
||||
if (nextZoom < previousZoom) {
|
||||
centerPanForZoom('base', nextZoom);
|
||||
} else if (nextZoom > previousZoom) {
|
||||
clampPanForZoom('base', nextZoom);
|
||||
}
|
||||
} else {
|
||||
setComparisonZoom(nextZoom);
|
||||
if (nextZoom < previousZoom) {
|
||||
centerPanForZoom('comparison', nextZoom);
|
||||
} else if (nextZoom > previousZoom) {
|
||||
clampPanForZoom('comparison', nextZoom);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (panDragRef.current.active && event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const dx = touch.clientX - panDragRef.current.startX;
|
||||
const dy = touch.clientY - panDragRef.current.startY;
|
||||
const isBase = panDragRef.current.source === 'base';
|
||||
const bounds = getPanBounds(panDragRef.current.source!);
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, panDragRef.current.startPanX - dx)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, panDragRef.current.startPanY - dy)),
|
||||
};
|
||||
if (isScrollLinked) {
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
const otherPane: Pane = isBase ? 'comparison' : 'base';
|
||||
const mappedPeer = mapPanBetweenOrientations(isBase ? 'base' : 'comparison', otherPane, desired);
|
||||
const peerBounds = getPanBounds(otherPane);
|
||||
const clampedPeer = {
|
||||
x: Math.max(0, Math.min(peerBounds.maxX, mappedPeer.x)),
|
||||
y: Math.max(0, Math.min(peerBounds.maxY, mappedPeer.y)),
|
||||
};
|
||||
if (isBase) {
|
||||
setComparisonPan(clampedPeer);
|
||||
} else {
|
||||
setBasePan(clampedPeer);
|
||||
}
|
||||
} else {
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations]
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
pinchRef.current.active = false;
|
||||
pinchRef.current.pane = null;
|
||||
panDragRef.current.active = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isScrollLinked) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = (target?.tagName || '').toLowerCase();
|
||||
const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true');
|
||||
if (isEditable) return;
|
||||
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) return;
|
||||
|
||||
const STEP = 80;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowDown' ? STEP : -STEP;
|
||||
isSyncingRef.current = true;
|
||||
baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight));
|
||||
compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight));
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [isScrollLinked]);
|
||||
|
||||
const captureScrollLinkDelta = useCallback(() => {
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) {
|
||||
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||
return;
|
||||
}
|
||||
const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight);
|
||||
const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight);
|
||||
const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth);
|
||||
const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth);
|
||||
|
||||
const baseV = baseEl.scrollTop / baseVMax;
|
||||
const compV = compEl.scrollTop / compVMax;
|
||||
const baseH = baseEl.scrollLeft / baseHMax;
|
||||
const compH = compEl.scrollLeft / compHMax;
|
||||
|
||||
scrollLinkDeltaRef.current = {
|
||||
vertical: compV - baseV,
|
||||
horizontal: compH - baseH,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearScrollLinkDelta = useCallback(() => {
|
||||
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||
}, []);
|
||||
|
||||
const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
setLayout,
|
||||
toggleLayout,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
captureScrollLinkDelta,
|
||||
clearScrollLinkDelta,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
setBaseZoom,
|
||||
comparisonZoom,
|
||||
setComparisonZoom,
|
||||
basePan,
|
||||
comparisonPan,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
handleScrollSync,
|
||||
beginPan,
|
||||
continuePan,
|
||||
endPan,
|
||||
handleWheelZoom,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
zoomLimits,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,192 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '../../../shared/LocalIcon';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { alert } from '../../../toast';
|
||||
import type { ToastLocation } from '../../../toast/types';
|
||||
import type { RightRailButtonWithAction } from '../../../../hooks/useRightRailButtons';
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
export interface UseCompareRightRailButtonsOptions {
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
toggleLayout: () => void;
|
||||
isPanMode: boolean;
|
||||
setIsPanMode: (value: boolean) => void;
|
||||
baseZoom: number;
|
||||
comparisonZoom: number;
|
||||
setBaseZoom: (value: number) => void;
|
||||
setComparisonZoom: (value: number) => void;
|
||||
centerPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
clampPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
clearScrollLinkDelta: () => void;
|
||||
captureScrollLinkDelta: () => void;
|
||||
isScrollLinked: boolean;
|
||||
setIsScrollLinked: (value: boolean) => void;
|
||||
zoomLimits: { min: number; max: number; step: number };
|
||||
}
|
||||
|
||||
export const useCompareRightRailButtons = ({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMemo<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'compare-toggle-layout',
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon={layout === 'side-by-side' ? 'vertical-split-rounded' : 'horizontal-split-rounded'}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: layout === 'side-by-side'
|
||||
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||
ariaLabel: layout === 'side-by-side'
|
||||
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||
section: 'top',
|
||||
order: 10,
|
||||
onClick: toggleLayout,
|
||||
},
|
||||
{
|
||||
id: 'compare-pan-mode',
|
||||
section: 'top',
|
||||
order: 12,
|
||||
render: ({ disabled }: { disabled: boolean }) => (
|
||||
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanMode ? 'default' : 'subtle'}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => setIsPanMode(!isPanMode)}
|
||||
disabled={disabled}
|
||||
aria-label={t('rightRail.panMode', 'Pan Mode')}
|
||||
style={isPanMode ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'compare-zoom-out',
|
||||
icon: <LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.zoomOut', 'Zoom out'),
|
||||
ariaLabel: t('compare.actions.zoomOut', 'Zoom out'),
|
||||
section: 'top',
|
||||
order: 13,
|
||||
onClick: () => {
|
||||
const { min, step } = zoomLimits;
|
||||
const nextBase = Math.max(min, +(baseZoom - step).toFixed(2));
|
||||
const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2));
|
||||
setBaseZoom(nextBase);
|
||||
setComparisonZoom(nextComparison);
|
||||
centerPanForZoom('base', nextBase);
|
||||
centerPanForZoom('comparison', nextComparison);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-zoom-in',
|
||||
icon: <LocalIcon icon="zoom-in" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.zoomIn', 'Zoom in'),
|
||||
ariaLabel: t('compare.actions.zoomIn', 'Zoom in'),
|
||||
section: 'top',
|
||||
order: 14,
|
||||
onClick: () => {
|
||||
const { max, step } = zoomLimits;
|
||||
const nextBase = Math.min(max, +(baseZoom + step).toFixed(2));
|
||||
const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2));
|
||||
setBaseZoom(nextBase);
|
||||
setComparisonZoom(nextComparison);
|
||||
clampPanForZoom('base', nextBase);
|
||||
clampPanForZoom('comparison', nextComparison);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-reset-view',
|
||||
icon: <LocalIcon icon="refresh-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||
section: 'top',
|
||||
order: 14.5,
|
||||
onClick: () => {
|
||||
setBaseZoom(1);
|
||||
setComparisonZoom(1);
|
||||
centerPanForZoom('base', 1);
|
||||
centerPanForZoom('comparison', 1);
|
||||
clearScrollLinkDelta();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-toggle-scroll-link',
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon={isScrollLinked ? 'link-rounded' : 'link-off-rounded'}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: isScrollLinked
|
||||
? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan')
|
||||
: t('compare.actions.linkScrollPan', 'Link scroll and pan'),
|
||||
ariaLabel: isScrollLinked
|
||||
? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan')
|
||||
: t('compare.actions.linkScrollPan', 'Link scroll and pan'),
|
||||
section: 'top',
|
||||
order: 15,
|
||||
onClick: () => {
|
||||
const next = !isScrollLinked;
|
||||
if (next) {
|
||||
captureScrollLinkDelta();
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.toasts.unlinkedTitle', 'Independent scroll & pan enabled'),
|
||||
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes; panning only moves the active pane.'),
|
||||
durationMs: 5000,
|
||||
location: 'bottom-center' as ToastLocation,
|
||||
expandable: false,
|
||||
});
|
||||
}
|
||||
setIsScrollLinked(next);
|
||||
},
|
||||
},
|
||||
], [
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
|
||||
export type UseCompareRightRailButtonsReturn = ReturnType<typeof useCompareRightRailButtons>;
|
||||
@ -12,6 +12,7 @@ export interface FilesStepConfig {
|
||||
minFiles?: number;
|
||||
onCollapsedClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
autoExpandNextOnFiles?: boolean;
|
||||
}
|
||||
|
||||
export interface MiddleStepConfig {
|
||||
@ -67,6 +68,7 @@ export interface ToolFlowConfig {
|
||||
*/
|
||||
export function createToolFlow(config: ToolFlowConfig) {
|
||||
const steps = createToolSteps();
|
||||
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="sm" p="sm" >
|
||||
@ -83,10 +85,11 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
})}
|
||||
|
||||
{/* Middle Steps */}
|
||||
{config.steps.map((stepConfig) =>
|
||||
{config.steps.map((stepConfig, index) =>
|
||||
steps.create(stepConfig.title, {
|
||||
isVisible: stepConfig.isVisible,
|
||||
isCollapsed: stepConfig.isCollapsed,
|
||||
// If enabled, auto-expand the first middle step when files exist
|
||||
isCollapsed: index === 0 && config.files.autoExpandNextOnFiles ? !hasFiles : stepConfig.isCollapsed,
|
||||
onCollapsedClick: stepConfig.onCollapsedClick,
|
||||
tooltip: stepConfig.tooltip
|
||||
}, stepConfig.content)
|
||||
|
||||
@ -30,11 +30,11 @@ export interface CustomWorkbenchViewRegistration {
|
||||
workbenchId: WorkbenchType;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
component: React.ComponentType<{ data: any }>;
|
||||
component: React.ComponentType<{ data: unknown }>;
|
||||
}
|
||||
|
||||
export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration {
|
||||
data: any;
|
||||
data: unknown | null;
|
||||
}
|
||||
|
||||
interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
@ -79,16 +79,21 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
customWorkbenchViews: CustomWorkbenchViewInstance[];
|
||||
registerCustomWorkbenchView: (view: CustomWorkbenchViewRegistration) => void;
|
||||
unregisterCustomWorkbenchView: (id: string) => void;
|
||||
setCustomWorkbenchViewData: (id: string, data: any) => void;
|
||||
setCustomWorkbenchViewData: (id: string, data: unknown) => void;
|
||||
clearCustomWorkbenchViewData: (id: string) => void;
|
||||
}
|
||||
|
||||
// Ensure a single context instance across HMR to avoid provider/consumer mismatches
|
||||
const __GLOBAL_CONTEXT_KEY__ = '__ToolWorkflowContext__';
|
||||
const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as React.Context<ToolWorkflowContextValue | undefined> | undefined;
|
||||
type ToolWorkflowGlobalThis = typeof globalThis & {
|
||||
[__GLOBAL_CONTEXT_KEY__]?: React.Context<ToolWorkflowContextValue | undefined>;
|
||||
};
|
||||
|
||||
const toolWorkflowGlobal = globalThis as ToolWorkflowGlobalThis;
|
||||
const existingContext = toolWorkflowGlobal[__GLOBAL_CONTEXT_KEY__];
|
||||
const ToolWorkflowContext = existingContext ?? createContext<ToolWorkflowContextValue | undefined>(undefined);
|
||||
if (!existingContext) {
|
||||
(globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
|
||||
toolWorkflowGlobal[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
|
||||
}
|
||||
|
||||
// Provider component
|
||||
@ -104,7 +109,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
|
||||
|
||||
const [customViewRegistry, setCustomViewRegistry] = React.useState<Record<string, CustomWorkbenchViewRegistration>>({});
|
||||
const [customViewData, setCustomViewData] = React.useState<Record<string, any>>({});
|
||||
const [customViewData, setCustomViewData] = React.useState<Record<string, unknown>>({});
|
||||
|
||||
// Navigation actions and state are available since we're inside NavigationProvider
|
||||
const { actions } = useNavigationActions();
|
||||
@ -196,7 +201,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
}
|
||||
}, [actions, navigationState.workbench]);
|
||||
|
||||
const setCustomWorkbenchViewData = useCallback((id: string, data: any) => {
|
||||
const setCustomWorkbenchViewData = useCallback((id: string, data: unknown) => {
|
||||
setCustomViewData(prev => ({ ...prev, [id]: data }));
|
||||
}, []);
|
||||
|
||||
|
||||
537
frontend/src/hooks/tools/compare/operationUtils.ts
Normal file
537
frontend/src/hooks/tools/compare/operationUtils.ts
Normal file
@ -0,0 +1,537 @@
|
||||
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
|
||||
import { appendWord as sharedAppendWord } from '../../../utils/textDiff';
|
||||
import type { StirlingFile } from '../../../types/fileContext';
|
||||
import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import type {
|
||||
CompareChange,
|
||||
CompareDiffToken,
|
||||
CompareResultData,
|
||||
TokenBoundingBox,
|
||||
CompareParagraph,
|
||||
} from '../../../types/compare';
|
||||
|
||||
export interface TokenMetadata {
|
||||
page: number;
|
||||
paragraph: number;
|
||||
bbox: TokenBoundingBox | null;
|
||||
}
|
||||
|
||||
export interface ExtractedContent {
|
||||
tokens: string[];
|
||||
metadata: TokenMetadata[];
|
||||
pageSizes: { width: number; height: number }[];
|
||||
paragraphs: CompareParagraph[];
|
||||
}
|
||||
|
||||
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
|
||||
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
|
||||
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
|
||||
let lastMeasurementFont = '';
|
||||
|
||||
const DEFAULT_CHAR_WIDTH = 1;
|
||||
const DEFAULT_SPACE_WIDTH = 0.33;
|
||||
|
||||
export const measureTextWidth = (fontSpec: string, text: string): number => {
|
||||
if (!measurementContext) {
|
||||
if (!text) return 0;
|
||||
if (text === ' ') return DEFAULT_SPACE_WIDTH;
|
||||
return text.length * DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
|
||||
if (lastMeasurementFont !== fontSpec) {
|
||||
measurementContext.font = fontSpec;
|
||||
lastMeasurementFont = fontSpec;
|
||||
}
|
||||
|
||||
const key = `${fontSpec}|${text}`;
|
||||
const cached = textMeasurementCache?.get(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const width = measurementContext.measureText(text).width || 0;
|
||||
textMeasurementCache?.set(key, width);
|
||||
return width;
|
||||
};
|
||||
|
||||
export const appendWord = (existing: string, word: string) => {
|
||||
if (!existing) {
|
||||
return sharedAppendWord('', word);
|
||||
}
|
||||
return sharedAppendWord(existing, word);
|
||||
};
|
||||
|
||||
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
|
||||
return tokens.reduce(
|
||||
(totals, token) => {
|
||||
if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized
|
||||
return totals;
|
||||
}
|
||||
switch (token.type) {
|
||||
case 'added':
|
||||
totals.added += 1;
|
||||
break;
|
||||
case 'removed':
|
||||
totals.removed += 1;
|
||||
break;
|
||||
default:
|
||||
totals.unchanged += 1;
|
||||
}
|
||||
return totals;
|
||||
},
|
||||
{ added: 0, removed: 0, unchanged: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
export const buildChanges = (
|
||||
tokens: CompareDiffToken[],
|
||||
baseMetadata: TokenMetadata[],
|
||||
comparisonMetadata: TokenMetadata[]
|
||||
): CompareChange[] => {
|
||||
const changes: CompareChange[] = [];
|
||||
let baseIndex = 0;
|
||||
let comparisonIndex = 0;
|
||||
let current: CompareChange | null = null;
|
||||
let currentBaseParagraph: number | null = null;
|
||||
let currentComparisonParagraph: number | null = null;
|
||||
|
||||
const ensureCurrent = (): CompareChange => {
|
||||
if (!current) {
|
||||
current = {
|
||||
id: `change-${changes.length}`,
|
||||
base: null,
|
||||
comparison: null,
|
||||
};
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
if (current.base) {
|
||||
current.base.text = current.base.text.trim();
|
||||
}
|
||||
if (current.comparison) {
|
||||
current.comparison.text = current.comparison.text.trim();
|
||||
}
|
||||
|
||||
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
|
||||
changes.push(current);
|
||||
}
|
||||
}
|
||||
current = null;
|
||||
currentBaseParagraph = null;
|
||||
currentComparisonParagraph = null;
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'removed') {
|
||||
const meta = baseMetadata[baseIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.base) {
|
||||
active.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentBaseParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentBaseParagraph !== null &&
|
||||
paragraph !== currentBaseParagraph &&
|
||||
active.base.text.trim().length > 0
|
||||
) {
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.base.text = appendWord(active.base.text, token.text);
|
||||
}
|
||||
if (meta && active.base.page === null) {
|
||||
active.base.page = meta.page;
|
||||
}
|
||||
if (meta && active.base.paragraph === null) {
|
||||
active.base.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentBaseParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === 'added') {
|
||||
const meta = comparisonMetadata[comparisonIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.comparison) {
|
||||
active.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentComparisonParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentComparisonParagraph !== null &&
|
||||
paragraph !== currentComparisonParagraph &&
|
||||
active.comparison.text.trim().length > 0
|
||||
) {
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.comparison.text = appendWord(active.comparison.text, token.text);
|
||||
}
|
||||
if (meta && active.comparison.page === null) {
|
||||
active.comparison.page = meta.page;
|
||||
}
|
||||
if (meta && active.comparison.paragraph === null) {
|
||||
active.comparison.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentComparisonParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// unchanged token
|
||||
flush();
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
export const createSummaryFile = (result: CompareResultData): File => {
|
||||
const exportPayload = {
|
||||
generatedAt: new Date(result.totals.processedAt).toISOString(),
|
||||
base: {
|
||||
name: result.base.fileName,
|
||||
totalWords: result.base.wordCount,
|
||||
},
|
||||
comparison: {
|
||||
name: result.comparison.fileName,
|
||||
totalWords: result.comparison.wordCount,
|
||||
},
|
||||
totals: {
|
||||
added: result.totals.added,
|
||||
removed: result.totals.removed,
|
||||
unchanged: result.totals.unchanged,
|
||||
durationMs: result.totals.durationMs,
|
||||
},
|
||||
changes: result.changes.map((change) => ({
|
||||
base: change.base,
|
||||
comparison: change.comparison,
|
||||
})),
|
||||
warnings: result.warnings,
|
||||
};
|
||||
|
||||
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
|
||||
};
|
||||
|
||||
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | undefined => {
|
||||
if (typeof value === 'object' && value !== null && 'code' in value) {
|
||||
const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' }).code;
|
||||
return potentialCode;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const tokens: string[] = [];
|
||||
const metadata: TokenMetadata[] = [];
|
||||
const pageSizes: { width: number; height: number }[] = [];
|
||||
const paragraphs: CompareParagraph[] = [];
|
||||
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
|
||||
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const content: TextContent = await page.getTextContent({
|
||||
disableCombineTextItems: true,
|
||||
} as Parameters<PDFPageProxy['getTextContent']>[0]);
|
||||
const styles: Record<string, { fontFamily?: string; ascent?: number; descent?: number }> = content.styles ?? {};
|
||||
|
||||
let paragraphIndex = 1;
|
||||
let paragraphBuffer = '';
|
||||
let prevItem: TextItem | null = null;
|
||||
|
||||
pageSizes.push({ width: viewport.width, height: viewport.height });
|
||||
|
||||
const normalizeToken = (s: string) =>
|
||||
s
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/[–—]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const isParagraphBreak = (curr: TextItem, prev: TextItem | null, yJumpThreshold = 6) => {
|
||||
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
|
||||
if (hasHardBreak) return true;
|
||||
if (!prev) return false;
|
||||
const prevY = prev.transform[5];
|
||||
const currY = curr.transform[5];
|
||||
return Math.abs(currY - prevY) > yJumpThreshold;
|
||||
};
|
||||
|
||||
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 0.004;
|
||||
const MIN_HORIZONTAL_PAD = 0.0012;
|
||||
const HORIZONTAL_PAD_RATIO = 0.12;
|
||||
const MIN_VERTICAL_PAD = 0.0008;
|
||||
const VERTICAL_PAD_RATIO = 0.18;
|
||||
|
||||
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
|
||||
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
|
||||
|
||||
let expandedLeft = left - horizontalPad;
|
||||
let expandedRight = left + width + horizontalPad;
|
||||
let expandedTop = top - verticalPad;
|
||||
let expandedBottom = top + height + verticalPad;
|
||||
|
||||
if (expandedRight - expandedLeft < MIN_WIDTH) {
|
||||
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
|
||||
expandedLeft -= deficit / 2;
|
||||
expandedRight += deficit / 2;
|
||||
}
|
||||
|
||||
expandedLeft = clamp(expandedLeft);
|
||||
expandedRight = clamp(expandedRight);
|
||||
expandedTop = clamp(expandedTop);
|
||||
expandedBottom = clamp(expandedBottom);
|
||||
|
||||
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: expandedLeft,
|
||||
top: expandedTop,
|
||||
width: expandedRight - expandedLeft,
|
||||
height: expandedBottom - expandedTop,
|
||||
};
|
||||
};
|
||||
|
||||
for (const item of content.items as TextItem[]) {
|
||||
if (!item?.str) {
|
||||
prevItem = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = item.str;
|
||||
const totalLen = Math.max(rawText.length, 1);
|
||||
const textStyle = item.fontName ? styles[item.fontName] : undefined;
|
||||
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
|
||||
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
|
||||
const fontSpec = `${fontScale}px ${fontFamily}`;
|
||||
|
||||
const weights: number[] = new Array(totalLen);
|
||||
let runningText = '';
|
||||
let previousAdvance = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
runningText += rawText[i];
|
||||
const advance = measureTextWidth(fontSpec, runningText);
|
||||
let width = advance - previousAdvance;
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
weights[i] = width;
|
||||
previousAdvance = advance;
|
||||
}
|
||||
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
}
|
||||
const prefix: number[] = new Array(totalLen + 1);
|
||||
prefix[0] = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
|
||||
const totalWeight = prefix[totalLen] || 1;
|
||||
|
||||
const rawX = item.transform[4];
|
||||
const rawY = item.transform[5];
|
||||
const transformed = [
|
||||
viewport.convertToViewportPoint(rawX, rawY),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY),
|
||||
viewport.convertToViewportPoint(rawX, rawY + item.height),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
|
||||
];
|
||||
const xs = transformed.map(([px]) => px);
|
||||
const ys = transformed.map(([, py]) => py);
|
||||
const left = Math.min(...xs);
|
||||
const right = Math.max(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const bottom = Math.max(...ys);
|
||||
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
|
||||
prevItem = item;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [baselineStart, baselineEnd, verticalEnd] = transformed;
|
||||
const baselineVector: [number, number] = [
|
||||
baselineEnd[0] - baselineStart[0],
|
||||
baselineEnd[1] - baselineStart[1],
|
||||
];
|
||||
const verticalVector: [number, number] = [
|
||||
verticalEnd[0] - baselineStart[0],
|
||||
verticalEnd[1] - baselineStart[1],
|
||||
];
|
||||
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
|
||||
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
|
||||
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
|
||||
|
||||
const font = item.fontName ? styles[item.fontName] : undefined;
|
||||
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
|
||||
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
|
||||
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
|
||||
|
||||
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = wordRegex.exec(rawText)) !== null) {
|
||||
const wordRaw = match[0];
|
||||
const normalizedWord = normalizeToken(wordRaw);
|
||||
if (!normalizedWord) {
|
||||
continue;
|
||||
}
|
||||
const startIndex = match.index;
|
||||
const endIndex = startIndex + wordRaw.length;
|
||||
|
||||
const relStart = prefix[startIndex] / totalWeight;
|
||||
const relEnd = prefix[endIndex] / totalWeight;
|
||||
|
||||
let wordLeftAbs: number;
|
||||
let wordRightAbs: number;
|
||||
let wordTopAbs: number;
|
||||
let wordBottomAbs: number;
|
||||
|
||||
if (hasOrientationVectors) {
|
||||
const segStart: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relStart,
|
||||
baselineStart[1] + baselineVector[1] * relStart,
|
||||
];
|
||||
const segEnd: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relEnd,
|
||||
baselineStart[1] + baselineVector[1] * relEnd,
|
||||
];
|
||||
const cornerPoints: Array<[number, number]> = [
|
||||
segStart,
|
||||
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
|
||||
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
|
||||
segEnd,
|
||||
];
|
||||
const cornerXs = cornerPoints.map(([px]) => px);
|
||||
const cornerYs = cornerPoints.map(([, py]) => py);
|
||||
wordLeftAbs = Math.min(...cornerXs);
|
||||
wordRightAbs = Math.max(...cornerXs);
|
||||
wordTopAbs = Math.min(...cornerYs);
|
||||
wordBottomAbs = Math.max(...cornerYs);
|
||||
} else {
|
||||
const segLeftAbs = left + (right - left) * relStart;
|
||||
const segRightAbs = left + (right - left) * relEnd;
|
||||
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
|
||||
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
|
||||
wordTopAbs = top;
|
||||
wordBottomAbs = bottom;
|
||||
}
|
||||
|
||||
const wordLeft = clamp(wordLeftAbs / viewport.width);
|
||||
const wordRight = clamp(wordRightAbs / viewport.width);
|
||||
const wordTop = clamp(wordTopAbs / viewport.height);
|
||||
const wordBottom = clamp(wordBottomAbs / viewport.height);
|
||||
const wordWidth = Math.max(0, wordRight - wordLeft);
|
||||
let wordHeight = Math.max(0, wordBottom - wordTop);
|
||||
|
||||
if (wordHeight > 0 && verticalScale < 1) {
|
||||
const midY = (wordTop + wordBottom) / 2;
|
||||
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
|
||||
const half = shrunkHeight / 2;
|
||||
const newTop = clamp(midY - half);
|
||||
const newBottom = clamp(midY + half);
|
||||
wordHeight = Math.max(0, newBottom - newTop);
|
||||
const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight);
|
||||
tokens.push(normalizedWord);
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
|
||||
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
|
||||
|
||||
tokens.push(normalizedWord);
|
||||
metadata.push({
|
||||
page: pageIndex,
|
||||
paragraph: paragraphIndex,
|
||||
bbox,
|
||||
});
|
||||
|
||||
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||
}
|
||||
|
||||
if (isParagraphBreak(item as TextItem, prevItem)) {
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
}
|
||||
tokens.push('\uE000PARA');
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
paragraphIndex += 1;
|
||||
}
|
||||
prevItem = item as TextItem;
|
||||
}
|
||||
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
tokens.push('\uE000PARA');
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
}
|
||||
}
|
||||
return { tokens, metadata, pageSizes, paragraphs };
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,69 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { appendWord as sharedAppendWord } from '../../../utils/textDiff';
|
||||
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
|
||||
import {
|
||||
ADDITION_HIGHLIGHT,
|
||||
CompareChange,
|
||||
CompareDiffToken,
|
||||
CompareResultData,
|
||||
CompareWorkerRequest,
|
||||
CompareWorkerResponse,
|
||||
CompareWorkerWarnings,
|
||||
REMOVAL_HIGHLIGHT,
|
||||
PARAGRAPH_SENTINEL,
|
||||
} from '../../../types/compare';
|
||||
import { CompareParameters } from './useCompareParameters';
|
||||
import { ToolOperationHook } from '../shared/useToolOperation';
|
||||
import type { StirlingFile } from '../../../types/fileContext';
|
||||
import { useFileContext } from '../../../contexts/file/fileHooks';
|
||||
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import type { TokenBoundingBox } from '../../../types/compare';
|
||||
import type { CompareParagraph } from '../../../types/compare';
|
||||
|
||||
interface TokenMetadata {
|
||||
page: number;
|
||||
paragraph: number;
|
||||
bbox: TokenBoundingBox | null;
|
||||
}
|
||||
|
||||
interface ExtractedContent {
|
||||
tokens: string[];
|
||||
metadata: TokenMetadata[];
|
||||
pageSizes: { width: number; height: number }[];
|
||||
paragraphs: CompareParagraph[];
|
||||
}
|
||||
|
||||
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
|
||||
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
|
||||
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
|
||||
let lastMeasurementFont = '';
|
||||
|
||||
const DEFAULT_CHAR_WIDTH = 1;
|
||||
const DEFAULT_SPACE_WIDTH = 0.33;
|
||||
|
||||
const measureTextWidth = (fontSpec: string, text: string): number => {
|
||||
if (!measurementContext) {
|
||||
if (!text) return 0;
|
||||
if (text === ' ') return DEFAULT_SPACE_WIDTH;
|
||||
return text.length * DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
|
||||
if (lastMeasurementFont !== fontSpec) {
|
||||
measurementContext.font = fontSpec;
|
||||
lastMeasurementFont = fontSpec;
|
||||
}
|
||||
|
||||
const key = `${fontSpec}|${text}`;
|
||||
const cached = textMeasurementCache?.get(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const width = measurementContext.measureText(text).width || 0;
|
||||
textMeasurementCache?.set(key, width);
|
||||
return width;
|
||||
};
|
||||
import {
|
||||
aggregateTotals,
|
||||
buildChanges,
|
||||
createSummaryFile,
|
||||
extractContentFromPdf,
|
||||
getWorkerErrorCode,
|
||||
} from './operationUtils';
|
||||
|
||||
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
|
||||
result: CompareResultData | null;
|
||||
@ -76,510 +32,7 @@ const DEFAULT_WORKER_SETTINGS = {
|
||||
maxWordThreshold: 200000,
|
||||
};
|
||||
|
||||
const aggregateTotals = (tokens: CompareDiffToken[]) => {
|
||||
return tokens.reduce(
|
||||
(totals, token) => {
|
||||
if (token.text === PARAGRAPH_SENTINEL) {
|
||||
return totals;
|
||||
}
|
||||
switch (token.type) {
|
||||
case 'added':
|
||||
totals.added += 1;
|
||||
break;
|
||||
case 'removed':
|
||||
totals.removed += 1;
|
||||
break;
|
||||
default:
|
||||
totals.unchanged += 1;
|
||||
}
|
||||
return totals;
|
||||
},
|
||||
{ added: 0, removed: 0, unchanged: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
const shouldConcatWithoutSpace = (word: string) => {
|
||||
return /^[.,!?;:)\]\}]/.test(word) || word.startsWith("'") || word === "'s";
|
||||
};
|
||||
|
||||
const appendWord = (existing: string, word: string) => {
|
||||
if (!existing) {
|
||||
return sharedAppendWord('', word);
|
||||
}
|
||||
return sharedAppendWord(existing, word);
|
||||
};
|
||||
|
||||
const buildChanges = (
|
||||
tokens: CompareDiffToken[],
|
||||
baseMetadata: TokenMetadata[],
|
||||
comparisonMetadata: TokenMetadata[]
|
||||
): CompareChange[] => {
|
||||
const changes: CompareChange[] = [];
|
||||
let baseIndex = 0;
|
||||
let comparisonIndex = 0;
|
||||
let current: CompareChange | null = null;
|
||||
let currentBaseParagraph: number | null = null;
|
||||
let currentComparisonParagraph: number | null = null;
|
||||
|
||||
const ensureCurrent = (): CompareChange => {
|
||||
if (!current) {
|
||||
current = {
|
||||
id: `change-${changes.length}`,
|
||||
base: null,
|
||||
comparison: null,
|
||||
};
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
if (current.base) {
|
||||
current.base.text = current.base.text.trim();
|
||||
}
|
||||
if (current.comparison) {
|
||||
current.comparison.text = current.comparison.text.trim();
|
||||
}
|
||||
|
||||
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
|
||||
changes.push(current);
|
||||
}
|
||||
}
|
||||
current = null;
|
||||
currentBaseParagraph = null;
|
||||
currentComparisonParagraph = null;
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
// Treat paragraph sentinels as hard boundaries, not visible changes
|
||||
if (token.text === PARAGRAPH_SENTINEL) {
|
||||
if (token.type === 'removed' && baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
if (token.type === 'added' && comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
if (token.type === 'removed') {
|
||||
const meta = baseMetadata[baseIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.base) {
|
||||
active.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentBaseParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentBaseParagraph !== null &&
|
||||
paragraph !== currentBaseParagraph &&
|
||||
active.base.text.trim().length > 0
|
||||
) {
|
||||
// Start a new change for a new paragraph to avoid ballooning
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.base.text = appendWord(active.base.text, token.text);
|
||||
}
|
||||
if (meta && active.base.page === null) {
|
||||
active.base.page = meta.page;
|
||||
}
|
||||
if (meta && active.base.paragraph === null) {
|
||||
active.base.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentBaseParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === 'added') {
|
||||
const meta = comparisonMetadata[comparisonIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.comparison) {
|
||||
active.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentComparisonParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentComparisonParagraph !== null &&
|
||||
paragraph !== currentComparisonParagraph &&
|
||||
active.comparison.text.trim().length > 0
|
||||
) {
|
||||
// Start a new change for a new paragraph to avoid ballooning
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.comparison.text = appendWord(active.comparison.text, token.text);
|
||||
}
|
||||
if (meta && active.comparison.page === null) {
|
||||
active.comparison.page = meta.page;
|
||||
}
|
||||
if (meta && active.comparison.paragraph === null) {
|
||||
active.comparison.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentComparisonParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// unchanged token
|
||||
flush();
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
const createSummaryFile = (result: CompareResultData): File => {
|
||||
const exportPayload = {
|
||||
generatedAt: new Date(result.totals.processedAt).toISOString(),
|
||||
base: {
|
||||
name: result.base.fileName,
|
||||
totalWords: result.base.wordCount,
|
||||
},
|
||||
comparison: {
|
||||
name: result.comparison.fileName,
|
||||
totalWords: result.comparison.wordCount,
|
||||
},
|
||||
totals: {
|
||||
added: result.totals.added,
|
||||
removed: result.totals.removed,
|
||||
unchanged: result.totals.unchanged,
|
||||
durationMs: result.totals.durationMs,
|
||||
},
|
||||
changes: result.changes.map((change) => ({
|
||||
base: change.base,
|
||||
comparison: change.comparison,
|
||||
})),
|
||||
warnings: result.warnings,
|
||||
};
|
||||
|
||||
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
|
||||
};
|
||||
|
||||
const clamp = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const tokens: string[] = [];
|
||||
const metadata: TokenMetadata[] = [];
|
||||
const pageSizes: { width: number; height: number }[] = [];
|
||||
const paragraphs: CompareParagraph[] = [];
|
||||
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
|
||||
const page = await pdfDoc.getPage(pageIndex);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
// pdf.js typings may not include disableCombineTextItems; pass via any
|
||||
const content = await (page as any).getTextContent({ disableCombineTextItems: true });
|
||||
const styles: Record<string, { fontFamily?: string }> = ((content as any).styles ?? {}) as Record<
|
||||
string,
|
||||
{ fontFamily?: string }
|
||||
>;
|
||||
|
||||
let paragraphIndex = 1;
|
||||
let paragraphBuffer = '';
|
||||
let prevItem: TextItem | null = null;
|
||||
|
||||
pageSizes.push({ width: viewport.width, height: viewport.height });
|
||||
|
||||
const normalizeToken = (s: string) =>
|
||||
s
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/[–—]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const normalizeAndSplit = (raw: string) => {
|
||||
const norm = normalizeToken(raw);
|
||||
const parts = norm.match(/[A-Za-z0-9]+|[^\sA-Za-z0-9]/g) || [];
|
||||
return parts.filter(Boolean);
|
||||
};
|
||||
|
||||
const isParagraphBreak = (curr: TextItem, prev: TextItem | null, yJumpThreshold = 6) => {
|
||||
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
|
||||
if (hasHardBreak) return true;
|
||||
if (!prev) return false;
|
||||
const prevY = prev.transform[5];
|
||||
const currY = curr.transform[5];
|
||||
return Math.abs(currY - prevY) > yJumpThreshold;
|
||||
};
|
||||
|
||||
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 0.004; // ensure very short tokens still get a visible highlight
|
||||
const MIN_HORIZONTAL_PAD = 0.0012;
|
||||
const HORIZONTAL_PAD_RATIO = 0.12;
|
||||
const MIN_VERTICAL_PAD = 0.0008;
|
||||
const VERTICAL_PAD_RATIO = 0.18;
|
||||
|
||||
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
|
||||
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
|
||||
|
||||
let expandedLeft = left - horizontalPad;
|
||||
let expandedRight = left + width + horizontalPad;
|
||||
let expandedTop = top - verticalPad;
|
||||
let expandedBottom = top + height + verticalPad;
|
||||
|
||||
if (expandedRight - expandedLeft < MIN_WIDTH) {
|
||||
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
|
||||
expandedLeft -= deficit / 2;
|
||||
expandedRight += deficit / 2;
|
||||
}
|
||||
|
||||
expandedLeft = clamp(expandedLeft);
|
||||
expandedRight = clamp(expandedRight);
|
||||
expandedTop = clamp(expandedTop);
|
||||
expandedBottom = clamp(expandedBottom);
|
||||
|
||||
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: expandedLeft,
|
||||
top: expandedTop,
|
||||
width: expandedRight - expandedLeft,
|
||||
height: expandedBottom - expandedTop,
|
||||
};
|
||||
};
|
||||
|
||||
for (const item of content.items as TextItem[]) {
|
||||
if (!item?.str) {
|
||||
prevItem = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute a per-word bounding box within this TextItem by proportionally
|
||||
// subdividing the item's horizontal span based on character weights
|
||||
// (simple glyph-width heuristic) rather than naive character counts.
|
||||
const rawText = item.str;
|
||||
const totalLen = Math.max(rawText.length, 1);
|
||||
const styles: any = (content as any).styles || {};
|
||||
|
||||
const textStyle = styles[item.fontName] as { fontFamily?: string } | undefined;
|
||||
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
|
||||
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
|
||||
const fontSpec = `${fontScale}px ${fontFamily}`;
|
||||
|
||||
const weights: number[] = new Array(totalLen);
|
||||
let runningText = '';
|
||||
let previousAdvance = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
runningText += rawText[i];
|
||||
const advance = measureTextWidth(fontSpec, runningText);
|
||||
let width = advance - previousAdvance;
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
weights[i] = width;
|
||||
previousAdvance = advance;
|
||||
}
|
||||
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
}
|
||||
const prefix: number[] = new Array(totalLen + 1);
|
||||
prefix[0] = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
|
||||
const totalWeight = prefix[totalLen] || 1;
|
||||
|
||||
const rawX = item.transform[4];
|
||||
const rawY = item.transform[5];
|
||||
const transformed = [
|
||||
viewport.convertToViewportPoint(rawX, rawY),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY),
|
||||
viewport.convertToViewportPoint(rawX, rawY + item.height),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
|
||||
];
|
||||
const xs = transformed.map(([px]) => px);
|
||||
const ys = transformed.map(([, py]) => py);
|
||||
const left = Math.min(...xs);
|
||||
const right = Math.max(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const bottom = Math.max(...ys);
|
||||
|
||||
if (
|
||||
!Number.isFinite(left) ||
|
||||
!Number.isFinite(right) ||
|
||||
!Number.isFinite(top) ||
|
||||
!Number.isFinite(bottom)
|
||||
) {
|
||||
prevItem = item;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [baselineStart, baselineEnd, verticalEnd] = transformed;
|
||||
const baselineVector: [number, number] = [
|
||||
baselineEnd[0] - baselineStart[0],
|
||||
baselineEnd[1] - baselineStart[1],
|
||||
];
|
||||
const verticalVector: [number, number] = [
|
||||
verticalEnd[0] - baselineStart[0],
|
||||
verticalEnd[1] - baselineStart[1],
|
||||
];
|
||||
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
|
||||
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
|
||||
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
|
||||
|
||||
// Tighten vertical boxes using font ascent/descent when available
|
||||
const fontName: string | undefined = (item as any).fontName;
|
||||
const font = fontName ? styles[fontName] : undefined;
|
||||
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
|
||||
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
|
||||
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
|
||||
|
||||
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = wordRegex.exec(rawText)) !== null) {
|
||||
const wordRaw = match[0];
|
||||
const normalizedWord = normalizeToken(wordRaw);
|
||||
if (!normalizedWord) {
|
||||
continue;
|
||||
}
|
||||
const startIndex = match.index;
|
||||
const endIndex = startIndex + wordRaw.length;
|
||||
|
||||
const relStart = prefix[startIndex] / totalWeight;
|
||||
const relEnd = prefix[endIndex] / totalWeight;
|
||||
|
||||
let wordLeftAbs: number;
|
||||
let wordRightAbs: number;
|
||||
let wordTopAbs: number;
|
||||
let wordBottomAbs: number;
|
||||
|
||||
if (hasOrientationVectors) {
|
||||
const segStart: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relStart,
|
||||
baselineStart[1] + baselineVector[1] * relStart,
|
||||
];
|
||||
const segEnd: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relEnd,
|
||||
baselineStart[1] + baselineVector[1] * relEnd,
|
||||
];
|
||||
const cornerPoints: Array<[number, number]> = [
|
||||
segStart,
|
||||
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
|
||||
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
|
||||
segEnd,
|
||||
];
|
||||
const cornerXs = cornerPoints.map(([px]) => px);
|
||||
const cornerYs = cornerPoints.map(([, py]) => py);
|
||||
wordLeftAbs = Math.min(...cornerXs);
|
||||
wordRightAbs = Math.max(...cornerXs);
|
||||
wordTopAbs = Math.min(...cornerYs);
|
||||
wordBottomAbs = Math.max(...cornerYs);
|
||||
} else {
|
||||
const segLeftAbs = left + (right - left) * relStart;
|
||||
const segRightAbs = left + (right - left) * relEnd;
|
||||
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
|
||||
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
|
||||
wordTopAbs = top;
|
||||
wordBottomAbs = bottom;
|
||||
}
|
||||
|
||||
let wordLeft = clamp(wordLeftAbs / viewport.width);
|
||||
let wordRight = clamp(wordRightAbs / viewport.width);
|
||||
let wordTop = clamp(wordTopAbs / viewport.height);
|
||||
let wordBottom = clamp(wordBottomAbs / viewport.height);
|
||||
const wordWidth = Math.max(0, wordRight - wordLeft);
|
||||
let wordHeight = Math.max(0, wordBottom - wordTop);
|
||||
|
||||
if (wordHeight > 0 && verticalScale < 1) {
|
||||
const midY = (wordTop + wordBottom) / 2;
|
||||
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
|
||||
const half = shrunkHeight / 2;
|
||||
wordTop = clamp(midY - half);
|
||||
wordBottom = clamp(midY + half);
|
||||
wordHeight = Math.max(0, wordBottom - wordTop);
|
||||
}
|
||||
|
||||
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
|
||||
|
||||
tokens.push(normalizedWord);
|
||||
metadata.push({
|
||||
page: pageIndex,
|
||||
paragraph: paragraphIndex,
|
||||
bbox,
|
||||
});
|
||||
|
||||
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||
}
|
||||
|
||||
if (isParagraphBreak(item, prevItem)) {
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
}
|
||||
tokens.push(PARAGRAPH_SENTINEL);
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
paragraphIndex += 1;
|
||||
}
|
||||
prevItem = item;
|
||||
}
|
||||
|
||||
// Flush any dangling paragraph at end of page
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
tokens.push(PARAGRAPH_SENTINEL);
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
}
|
||||
}
|
||||
return { tokens, metadata, pageSizes, paragraphs };
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
}
|
||||
};
|
||||
// extractContentFromPdf moved to utils
|
||||
|
||||
export const useCompareOperation = (): CompareOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
@ -659,8 +112,8 @@ export const useCompareOperation = (): CompareOperationHook => {
|
||||
break;
|
||||
case 'error': {
|
||||
cleanup();
|
||||
const error = new Error(message.message);
|
||||
(error as any).code = message.code;
|
||||
const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' } = new Error(message.message);
|
||||
error.code = message.code;
|
||||
reject(error);
|
||||
break;
|
||||
}
|
||||
@ -816,18 +269,22 @@ export const useCompareOperation = (): CompareOperationHook => {
|
||||
setDownloadFilename(summaryFile.name);
|
||||
|
||||
setStatus(t('compare.status.complete', 'Comparison ready'));
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error('[compare] operation failed', error);
|
||||
if (error?.code === 'TOO_LARGE') {
|
||||
const errorCode = getWorkerErrorCode(error);
|
||||
if (errorCode === 'TOO_LARGE') {
|
||||
setErrorMessage(warningMessages.tooLargeMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
|
||||
} else if (error?.code === 'EMPTY_TEXT') {
|
||||
} else if (errorCode === 'EMPTY_TEXT') {
|
||||
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
|
||||
} else {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('compare.error.generic', 'Unable to compare these files.')
|
||||
);
|
||||
const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.');
|
||||
if (error instanceof Error && error.message) {
|
||||
setErrorMessage(error.message);
|
||||
} else if (typeof error === 'string' && error.trim().length > 0) {
|
||||
setErrorMessage(error);
|
||||
} else {
|
||||
setErrorMessage(fallbackMessage);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const duration = performance.now() - operationStart;
|
||||
|
||||
284
frontend/src/hooks/useProgressivePagePreviews.ts
Normal file
284
frontend/src/hooks/useProgressivePagePreviews.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
|
||||
// Define PagePreview type locally since it's specific to this hook
|
||||
export interface PagePreview {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const DISPLAY_SCALE = 1;
|
||||
const BATCH_SIZE = 10; // Render 10 pages at a time
|
||||
|
||||
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||
|
||||
interface ProgressivePagePreviewsOptions {
|
||||
file: File | null;
|
||||
enabled: boolean;
|
||||
cacheKey: number | null;
|
||||
visiblePageRange?: { start: number; end: number }; // 0-based page indices
|
||||
}
|
||||
|
||||
interface ProgressivePagePreviewsState {
|
||||
pages: PagePreview[];
|
||||
loading: boolean;
|
||||
totalPages: number;
|
||||
loadedPages: Set<number>; // 0-based page indices that have been loaded
|
||||
loadingPages: Set<number>; // 0-based page indices currently being loaded
|
||||
}
|
||||
|
||||
export const useProgressivePagePreviews = ({
|
||||
file,
|
||||
enabled,
|
||||
cacheKey,
|
||||
visiblePageRange,
|
||||
}: ProgressivePagePreviewsOptions) => {
|
||||
const [state, setState] = useState<ProgressivePagePreviewsState>({
|
||||
pages: [],
|
||||
loading: false,
|
||||
totalPages: 0,
|
||||
loadedPages: new Set(),
|
||||
loadingPages: new Set(),
|
||||
});
|
||||
|
||||
const pdfRef = useRef<any>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const renderPageBatch = useCallback(async (
|
||||
pdf: any,
|
||||
pageNumbers: number[],
|
||||
signal: AbortSignal
|
||||
): Promise<PagePreview[]> => {
|
||||
const previews: PagePreview[] = [];
|
||||
const dpr = getDevicePixelRatio();
|
||||
const renderScale = Math.max(2, Math.min(3, dpr * 2));
|
||||
|
||||
for (const pageNumber of pageNumbers) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
|
||||
const renderViewport = page.getViewport({ scale: renderScale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
canvas.width = Math.round(renderViewport.width);
|
||||
canvas.height = Math.round(renderViewport.height);
|
||||
|
||||
if (!context) {
|
||||
page.cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||
previews.push({
|
||||
pageNumber,
|
||||
width: Math.round(displayViewport.width),
|
||||
height: Math.round(displayViewport.height),
|
||||
rotation: (page.rotate || 0) % 360,
|
||||
url: canvas.toDataURL(),
|
||||
});
|
||||
|
||||
page.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
} catch (error) {
|
||||
console.error(`[progressive-pages] failed to render page ${pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return previews;
|
||||
}, []);
|
||||
|
||||
const loadPageRange = useCallback(async (
|
||||
startPage: number,
|
||||
endPage: number,
|
||||
signal: AbortSignal
|
||||
) => {
|
||||
// Use the live PDF ref for bounds instead of possibly stale state
|
||||
const totalPages = pdfRef.current?.numPages ?? state.totalPages;
|
||||
if (startPage < 0 || endPage >= totalPages || startPage > endPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which pages need to be loaded
|
||||
const pagesToLoad: number[] = [];
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
if (!state.loadedPages.has(i) && !state.loadingPages.has(i)) {
|
||||
pagesToLoad.push(i + 1); // Convert to 1-based page numbers
|
||||
}
|
||||
}
|
||||
|
||||
if (pagesToLoad.length === 0) return;
|
||||
|
||||
// Mark pages as loading
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loadingPages: new Set([...prev.loadingPages, ...pagesToLoad.map(p => p - 1)]),
|
||||
}));
|
||||
|
||||
try {
|
||||
const previews = await renderPageBatch(pdfRef.current, pagesToLoad, signal);
|
||||
|
||||
if (!signal.aborted) {
|
||||
setState(prev => {
|
||||
const newPages = [...prev.pages];
|
||||
const newLoadedPages = new Set(prev.loadedPages);
|
||||
const newLoadingPages = new Set(prev.loadingPages);
|
||||
|
||||
// Add new previews and mark as loaded
|
||||
for (const preview of previews) {
|
||||
const pageIndex = preview.pageNumber - 1; // Convert to 0-based
|
||||
newLoadedPages.add(pageIndex);
|
||||
newLoadingPages.delete(pageIndex);
|
||||
|
||||
// Insert preview in correct position
|
||||
const insertIndex = newPages.findIndex(p => p.pageNumber > preview.pageNumber);
|
||||
if (insertIndex === -1) {
|
||||
newPages.push(preview);
|
||||
} else {
|
||||
newPages.splice(insertIndex, 0, preview);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
pages: newPages,
|
||||
loadedPages: newLoadedPages,
|
||||
loadingPages: newLoadingPages,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
console.error('[progressive-pages] failed to load page batch:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setState(prev => {
|
||||
const newLoadingPages = new Set(prev.loadingPages);
|
||||
pagesToLoad.forEach(p => newLoadingPages.delete(p - 1));
|
||||
return { ...prev, loadingPages: newLoadingPages };
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [state.loadedPages, state.loadingPages, state.totalPages, renderPageBatch]);
|
||||
|
||||
// Initialize PDF and load first batch
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!file || !enabled) {
|
||||
setState({
|
||||
pages: [],
|
||||
loading: false,
|
||||
totalPages: 0,
|
||||
loadedPages: new Set(),
|
||||
loadingPages: new Set(),
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const initialize = async () => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
return;
|
||||
}
|
||||
|
||||
pdfRef.current = pdf;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
totalPages,
|
||||
loading: false,
|
||||
}));
|
||||
|
||||
// Load first batch of pages using a real abort controller
|
||||
const initAbort = new AbortController();
|
||||
const firstBatchEnd = Math.min(BATCH_SIZE - 1, totalPages - 1);
|
||||
await loadPageRange(0, firstBatchEnd, initAbort.signal);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[progressive-pages] failed to initialize PDF:', error);
|
||||
if (!cancelled) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
totalPages: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pdfRef.current) {
|
||||
pdfWorkerManager.destroyDocument(pdfRef.current);
|
||||
pdfRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [file, enabled, cacheKey, loadPageRange]);
|
||||
|
||||
// Load pages based on visible range
|
||||
useEffect(() => {
|
||||
if (!visiblePageRange || state.totalPages === 0) return;
|
||||
|
||||
const { start, end } = visiblePageRange;
|
||||
const startPage = Math.max(0, start - 5); // Add buffer before
|
||||
const endPage = Math.min(state.totalPages - 1, end + 5); // Add buffer after
|
||||
|
||||
// Cancel previous loading
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
loadPageRange(startPage, endPage, abortController.signal);
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [visiblePageRange, state.totalPages, loadPageRange]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
if (pdfRef.current) {
|
||||
pdfWorkerManager.destroyDocument(pdfRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pages: state.pages,
|
||||
loading: state.loading,
|
||||
totalPages: state.totalPages,
|
||||
loadedPages: state.loadedPages,
|
||||
loadingPages: state.loadingPages,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseProgressivePagePreviewsReturn = ReturnType<typeof useProgressivePagePreviews>;
|
||||
@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
|
||||
import { Box, Card, Group, Stack, Text, Button } from '@mantine/core';
|
||||
import { Box, Group, Stack, Text, Button } from '@mantine/core';
|
||||
import { createToolFlow } from '../components/tools/shared/createToolFlow';
|
||||
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
|
||||
import { BaseToolProps, ToolComponent } from '../types/tool';
|
||||
import {
|
||||
@ -15,12 +16,12 @@ import {
|
||||
import CompareWorkbenchView from '../components/tools/compare/CompareWorkbenchView';
|
||||
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
|
||||
import { useFileActions, useFileContext } from '../contexts/file/fileHooks';
|
||||
import { useFileContext } from '../contexts/file/fileHooks';
|
||||
import type { FileId } from '../types/file';
|
||||
import { createToolFlow } from '../components/tools/shared/createToolFlow';
|
||||
import type { StirlingFile } from '../types/fileContext';
|
||||
import DocumentThumbnail from '../components/shared/filePreview/DocumentThumbnail';
|
||||
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||
import './compareTool.css';
|
||||
import type { CompareWorkbenchData } from '../types/compareWorkbench';
|
||||
|
||||
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
|
||||
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
|
||||
@ -36,8 +37,6 @@ const Compare = (props: BaseToolProps) => {
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
const { selectors } = useFileContext();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
const base = useBaseTool(
|
||||
'compare',
|
||||
@ -59,7 +58,7 @@ const Compare = (props: BaseToolProps) => {
|
||||
// Use a static label at registration time to avoid re-registering on i18n changes
|
||||
label: 'Compare view',
|
||||
icon: compareIcon,
|
||||
component: CompareWorkbenchView,
|
||||
component: CompareWorkbenchView as any,
|
||||
});
|
||||
|
||||
return () => {
|
||||
@ -69,18 +68,26 @@ const Compare = (props: BaseToolProps) => {
|
||||
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
|
||||
}, []);
|
||||
|
||||
// Map the first two selected workbench files into base/comparison in order
|
||||
// Auto-map from workbench selection: always reflect the first two selected files in order.
|
||||
// This also handles deselection by promoting the remaining selection to base and clearing comparison.
|
||||
useEffect(() => {
|
||||
const first = base.selectedFiles[0]?.fileId as FileId | undefined;
|
||||
const second = base.selectedFiles[1]?.fileId as FileId | undefined;
|
||||
const selectedIds = base.selectedFiles.map(f => f.fileId as FileId);
|
||||
|
||||
const nextBase: FileId | null = first ?? null;
|
||||
const nextComp: FileId | null = second ?? null;
|
||||
// Determine next base: keep current if still selected; otherwise use the first selected id
|
||||
const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId)
|
||||
? (params.baseFileId as FileId)
|
||||
: (selectedIds[0] ?? null);
|
||||
|
||||
// Removed verbose diagnostics
|
||||
// Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id
|
||||
let nextComp: FileId | null = null;
|
||||
if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) {
|
||||
nextComp = params.comparisonFileId as FileId;
|
||||
} else {
|
||||
nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null;
|
||||
}
|
||||
|
||||
if (params.baseFileId !== nextBase || params.comparisonFileId !== nextComp) {
|
||||
base.params.setParameters((prev: any) => ({
|
||||
if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) {
|
||||
base.params.setParameters(prev => ({
|
||||
...prev,
|
||||
baseFileId: nextBase,
|
||||
comparisonFileId: nextComp,
|
||||
@ -105,13 +112,14 @@ const Compare = (props: BaseToolProps) => {
|
||||
|
||||
if (matchesSelection && result && processedAt !== null && processedAt !== lastProcessedAtRef.current) {
|
||||
|
||||
setCustomWorkbenchViewData(CUSTOM_VIEW_ID, {
|
||||
const workbenchData: CompareWorkbenchData = {
|
||||
result,
|
||||
baseFileId,
|
||||
comparisonFileId,
|
||||
baseLocalFile: null,
|
||||
comparisonLocalFile: null,
|
||||
});
|
||||
};
|
||||
setCustomWorkbenchViewData(CUSTOM_VIEW_ID, workbenchData);
|
||||
// Defer workbench switch to the next frame so the data update is visible to the provider
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@ -136,22 +144,48 @@ const Compare = (props: BaseToolProps) => {
|
||||
params,
|
||||
]);
|
||||
|
||||
const handleOpenWorkbench = useCallback(() => {
|
||||
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
|
||||
}, [navigationActions]);
|
||||
// const handleOpenWorkbench = useCallback(() => {
|
||||
// navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
|
||||
// }, [navigationActions]);
|
||||
|
||||
const handleExecuteCompare = useCallback(async () => {
|
||||
const selected: any[] = [];
|
||||
const selected: StirlingFile[] = [];
|
||||
const baseSel = params.baseFileId ? selectors.getFile(params.baseFileId) : null;
|
||||
const compSel = params.comparisonFileId ? selectors.getFile(params.comparisonFileId) : null;
|
||||
if (baseSel) selected.push(baseSel);
|
||||
if (compSel) selected.push(compSel);
|
||||
await operation.executeOperation(
|
||||
{ ...params } as any,
|
||||
selected as any
|
||||
{ ...params },
|
||||
selected
|
||||
);
|
||||
}, [operation, params, selectors]);
|
||||
|
||||
// Run compare with explicit ids (used after swap so we don't depend on async state propagation)
|
||||
const runCompareWithIds = useCallback(async (baseId: FileId | null, compId: FileId | null) => {
|
||||
const nextParams = { ...params, baseFileId: baseId, comparisonFileId: compId };
|
||||
const selected: StirlingFile[] = [];
|
||||
const baseSel = baseId ? selectors.getFile(baseId) : null;
|
||||
const compSel = compId ? selectors.getFile(compId) : null;
|
||||
if (baseSel) selected.push(baseSel);
|
||||
if (compSel) selected.push(compSel);
|
||||
await operation.executeOperation(nextParams, selected);
|
||||
}, [operation, params, selectors]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
const baseId = params.baseFileId as FileId | null;
|
||||
const compId = params.comparisonFileId as FileId | null;
|
||||
if (!baseId || !compId) return;
|
||||
base.params.setParameters((prev) => ({
|
||||
...prev,
|
||||
baseFileId: compId,
|
||||
comparisonFileId: baseId,
|
||||
}));
|
||||
// If we already have a comparison result, re-run automatically using the swapped ids.
|
||||
if (operation.result) {
|
||||
runCompareWithIds(compId, baseId);
|
||||
}
|
||||
}, [base.params, params.baseFileId, params.comparisonFileId, operation.result, runCompareWithIds]);
|
||||
|
||||
const renderSelectedFile = useCallback(
|
||||
(role: 'base' | 'comparison') => {
|
||||
const fileId = role === 'base' ? params.baseFileId : params.comparisonFileId;
|
||||
@ -159,11 +193,21 @@ const Compare = (props: BaseToolProps) => {
|
||||
|
||||
if (!stub) {
|
||||
return (
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Box
|
||||
style={{
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg-surface)'
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(role === 'base' ? 'compare.base.placeholder' : 'compare.comparison.placeholder', role === 'base' ? 'Select a base PDF' : 'Select a comparison PDF')}
|
||||
{t(
|
||||
role === 'base' ? 'compare.base.placeholder' : 'compare.comparison.placeholder',
|
||||
role === 'base' ? 'Select a base PDF' : 'Select a comparison PDF'
|
||||
)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@ -177,10 +221,17 @@ const Compare = (props: BaseToolProps) => {
|
||||
.join(' - ');
|
||||
|
||||
return (
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Box
|
||||
style={{
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg-surface)'
|
||||
}}
|
||||
>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md">
|
||||
<Box className="compare-tool__thumbnail">
|
||||
<DocumentThumbnail file={stub as any} thumbnail={stub?.thumbnailUrl || null} />
|
||||
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
|
||||
</Box>
|
||||
<Stack gap={4} className="compare-tool__details">
|
||||
<Text fw={600} truncate>
|
||||
@ -191,36 +242,9 @@ const Compare = (props: BaseToolProps) => {
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
openFilesModal({
|
||||
maxNumberOfFiles: 1,
|
||||
customHandler: async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
try {
|
||||
const added = await fileActions.addFiles(files, { selectFiles: true });
|
||||
const primary = added[0];
|
||||
if (!primary) return;
|
||||
base.params.setParameters((prev: any) => ({
|
||||
...prev,
|
||||
baseFileId: role === 'base' ? (primary.fileId as FileId) : prev.baseFileId,
|
||||
comparisonFileId: role === 'comparison' ? (primary.fileId as FileId) : prev.comparisonFileId,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('[compare] replace file failed', e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
disabled={base.operation.isLoading}
|
||||
>
|
||||
{t('compare.upload.replaceFile', 'Replace file')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
[params.baseFileId, params.comparisonFileId, selectors, t]
|
||||
@ -231,23 +255,30 @@ const Compare = (props: BaseToolProps) => {
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
title: {
|
||||
title: t('compare.title', 'Compare Documents'),
|
||||
description: t('compare.description', 'Select the base and comparison PDF to highlight differences.'),
|
||||
},
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: false,
|
||||
autoExpandNextOnFiles: true, // Next step (selection) opens as soon as files exist
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t('compare.base.label', 'Base Document'),
|
||||
title: t('compare.selection.title', 'Select Base and Comparison'),
|
||||
isVisible: true,
|
||||
content: renderSelectedFile('base'),
|
||||
},
|
||||
{
|
||||
title: t('compare.comparison.label', 'Comparison Document'),
|
||||
isVisible: true,
|
||||
content: renderSelectedFile('comparison'),
|
||||
content: (
|
||||
<Stack gap="md">
|
||||
{renderSelectedFile('base')}
|
||||
{renderSelectedFile('comparison')}
|
||||
<Group justify="flex-start">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSwap}
|
||||
disabled={!params.baseFileId || !params.comparisonFileId || base.operation.isLoading}
|
||||
>
|
||||
{t('compare.swap', 'Swap PDFs')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
|
||||
@ -39,13 +39,18 @@ const ValidateSignature = (props: BaseToolProps) => {
|
||||
const hasResults = operation.results.length > 0;
|
||||
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
||||
|
||||
// Adapter to satisfy ComponentType<{ data: unknown }>
|
||||
const ReportAdapter: React.ComponentType<{ data: unknown }> = ({ data }) => (
|
||||
<ValidateSignatureReportView data={data as SignatureValidationReportData} />
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: REPORT_VIEW_ID,
|
||||
workbenchId: REPORT_WORKBENCH_ID,
|
||||
label: t('validateSignature.report.shortTitle', 'Signature Report'),
|
||||
icon: reportIcon,
|
||||
component: ValidateSignatureReportView,
|
||||
component: ReportAdapter,
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
20
frontend/src/types/compareWorkbench.ts
Normal file
20
frontend/src/types/compareWorkbench.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import type { CompareResultData } from './compare';
|
||||
import type { FileId } from './file';
|
||||
import type { StirlingFile } from './fileContext';
|
||||
|
||||
export interface CompareWorkbenchData {
|
||||
result: CompareResultData | null;
|
||||
baseFileId: FileId | null;
|
||||
comparisonFileId: FileId | null;
|
||||
onSelectBase?: (fileId: FileId | null) => void;
|
||||
onSelectComparison?: (fileId: FileId | null) => void;
|
||||
isLoading?: boolean;
|
||||
baseLocalFile?: StirlingFile | null;
|
||||
comparisonLocalFile?: StirlingFile | null;
|
||||
}
|
||||
|
||||
export interface CompareChangeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
pageNumber: number;
|
||||
}
|
||||
@ -3,7 +3,7 @@
|
||||
export const PARAGRAPH_SENTINEL = '\uE000¶';
|
||||
|
||||
export const shouldConcatWithoutSpace = (word: string) => {
|
||||
return /^[.,!?;:)\]\}]/.test(word) || word.startsWith("'") || word === "'s";
|
||||
return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s";
|
||||
};
|
||||
|
||||
export const appendWord = (existing: string, word: string) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user