almost PR ready, broke out components, remove unused, fixed bugs

This commit is contained in:
EthanHealy01 2025-10-24 22:45:27 +01:00
parent 5a0d975baf
commit 227a974a09
20 changed files with 2812 additions and 1952 deletions

View 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;

View File

@ -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();
}

View File

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

View File

@ -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();
}}
>

View 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

View File

@ -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>;

View File

@ -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,
};
};

View File

@ -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>;

View 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,
};
};

View File

@ -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>;

View File

@ -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)

View File

@ -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 }));
}, []);

View 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);
}
};

View File

@ -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;

View 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>;

View File

@ -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: {

View File

@ -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 () => {

View 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;
}

View File

@ -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) => {