fix bugs and style changes

This commit is contained in:
EthanHealy01 2025-10-31 01:05:40 +00:00
parent d1f2ffaaf2
commit 51dac11183
9 changed files with 420 additions and 263 deletions

View File

@ -7,6 +7,8 @@
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
/* Clear button disabled text color (default/light) */
--spdf-clear-disabled-text: var(--md-sys-color-primary);
/* Icon fill */
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
@ -25,6 +27,12 @@
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
}
/* Dark theme overrides */
.dark-theme {
/* In dark mode, use a neutral grey for disabled Clear button text */
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
}
.fill {
font-variation-settings: var(--md-sys-icon-fill-1);
}

View File

@ -1,76 +0,0 @@
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

@ -3,7 +3,6 @@ import { useMemo, useRef, useState } from 'react';
import type { PagePreview } from '@app/types/compare';
import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare';
import CompareNavigationDropdown from './CompareNavigationDropdown';
import LazyLoadContainer from '@app/components/shared/LazyLoadContainer';
import { useIsMobile } from '@app/hooks/useIsMobile';
const toRgba = (hexColor: string, alpha: number): string => {
@ -84,6 +83,10 @@ const CompareDocumentPane = ({
documentLabel,
pageLabel,
altLabel,
pageInputValue,
onPageInputChange,
maxSharedPages,
onVisiblePageChange,
}: CompareDocumentPaneProps) => {
const isMobileViewport = useIsMobile();
const pairedPageMap = useMemo(() => {
@ -98,12 +101,13 @@ const CompareDocumentPane = ({
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);
const pagePanRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const dragRef = useRef<{ active: boolean; page: number | null; startX: number; startY: number; startPanX: number; startPanY: number }>({ active: false, page: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 });
// Track which page images have finished loading to avoid flashing between states
const imageLoadedRef = useRef<Map<number, boolean>>(new Map());
const [, forceRerender] = useState(0);
const visiblePageRafRef = useRef<number | null>(null);
return (
<div className="compare-pane">
@ -112,7 +116,8 @@ const CompareDocumentPane = ({
<Text fw={600} size="lg">
{title}
</Text>
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
<CompareNavigationDropdown
changes={changes}
placeholder={dropdownPlaceholder ?? null}
@ -120,17 +125,45 @@ const CompareDocumentPane = ({
onNavigate={onNavigateChange}
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
/>
)}
)}
</Group>
</Group>
</div>
<div
ref={scrollRef}
onScroll={(event) => handleScrollSync(event.currentTarget, peerScrollRef.current)}
onMouseDown={(event) => beginPan(pane, event)}
onMouseMove={continuePan}
onMouseUp={endPan}
onMouseLeave={endPan}
onScroll={(event) => {
handleScrollSync(event.currentTarget, peerScrollRef.current);
// Notify parent about the currently visible page (throttled via rAF)
if (visiblePageRafRef.current != null) cancelAnimationFrame(visiblePageRafRef.current);
visiblePageRafRef.current = requestAnimationFrame(() => {
const container = scrollRef.current;
if (!container) return;
const mid = container.scrollTop + container.clientHeight * 0.5;
let bestPage = pages[0]?.pageNumber ?? 1;
let bestDist = Number.POSITIVE_INFINITY;
const nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[];
for (const el of nodes) {
const top = el.offsetTop;
const height = el.clientHeight || 1;
const center = top + height / 2;
const dist = Math.abs(center - mid);
if (dist < bestDist) {
bestDist = dist;
const attr = el.getAttribute('data-page-number');
const pn = attr ? parseInt(attr, 10) : NaN;
if (!Number.isNaN(pn)) bestPage = pn;
}
}
if (typeof onVisiblePageChange === 'function') {
onVisiblePageChange(pane, bestPage);
}
});
}}
onMouseDown={undefined}
onMouseMove={undefined}
onMouseUp={undefined}
onMouseLeave={undefined}
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
onTouchStart={(event) => onTouchStart(pane, event)}
onTouchMove={onTouchMove}
@ -138,7 +171,7 @@ const CompareDocumentPane = ({
className="compare-pane__scroll"
style={{ cursor: cursorStyle }}
>
<Stack gap="sm" className="compare-pane__content">
<Stack gap={zoom <= 0.6 ? 2 : zoom <= 0.85 ? 'xs' : 'sm'} className="compare-pane__content">
{isLoading && (
<Group justify="center" gap="xs" py="md">
<Loader size="sm" />
@ -174,71 +207,30 @@ const CompareDocumentPane = ({
}
const preloadMarginPx = Math.max(rowHeightPx * 5, 1200); // render several pages ahead to hide loading flashes
const baseWidth = isStackedPortrait
? stackedWidth
: Math.round(page.width * fit);
const baseHeight = isStackedPortrait
? stackedHeight
: Math.round(targetHeight);
const desiredWidth = Math.max(1, Math.round(baseWidth * Math.max(0.1, zoom)));
const desiredHeight = Math.max(1, Math.round(baseHeight * Math.max(0.1, zoom)));
const containerMaxW = scrollRef.current?.clientWidth ?? (typeof window !== 'undefined' ? window.innerWidth : desiredWidth);
const containerWidth = Math.min(desiredWidth, Math.max(120, containerMaxW));
const containerHeight = Math.round(baseHeight * (containerWidth / baseWidth));
const innerScale = Math.max(1, desiredWidth / containerWidth);
const currentPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
return (
<LazyLoadContainer
key={`${pane}-page-${page.pageNumber}`}
rootMargin={`${preloadMarginPx}px 0px ${preloadMarginPx}px 0px`}
threshold={0.1}
fallback={
<div
className="compare-diff-page"
data-page-number={page.pageNumber}
style={{ minHeight: `${rowHeightPx}px` }}
>
<div
className="compare-page-title"
style={isStackedPortrait
? { width: `${stackedWidth}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<Text size="xs" fw={600} c="dimmed" ta="center">
{documentLabel} · {pageLabel} {page.pageNumber}
</Text>
</div>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={isStackedPortrait
? { width: `${stackedWidth}px`, height: `${stackedHeight}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<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` }}
style={{ minHeight: `${containerHeight}px` }}
>
<div
className="compare-page-title"
style={isStackedPortrait
? { width: `${stackedWidth}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
style={{ width: `${containerWidth}px`, marginLeft: 'auto', marginRight: 'auto' }}
>
<Text size="xs" fw={600} c="dimmed" ta="center">
{documentLabel} · {pageLabel} {page.pageNumber}
@ -246,16 +238,49 @@ const CompareDocumentPane = ({
</div>
<div
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
style={isStackedPortrait
? { width: `${stackedWidth}px`, height: `${stackedHeight}px`, marginLeft: 'auto', marginRight: 'auto' }
: isStackedLandscape
? { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }
: { width: `${Math.round(page.width * fit)}px`, marginLeft: 'auto', marginRight: 'auto' }}
style={{ width: `${containerWidth}px`, height: `${containerHeight}px`, marginLeft: 'auto', marginRight: 'auto', overflow: 'hidden' }}
onMouseDown={(e) => {
if (!isPanMode || zoom <= 1) return;
dragRef.current.active = true;
dragRef.current.page = page.pageNumber;
dragRef.current.startX = e.clientX;
dragRef.current.startY = e.clientY;
const curr = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
dragRef.current.startPanX = curr.x;
dragRef.current.startPanY = curr.y;
(e.currentTarget as HTMLElement).style.cursor = 'grabbing';
e.preventDefault();
}}
onMouseMove={(e) => {
if (!dragRef.current.active || dragRef.current.page !== page.pageNumber) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
const maxX = Math.max(0, Math.round(baseWidth * innerScale - containerWidth));
const maxY = Math.max(0, Math.round(baseHeight * innerScale - containerHeight));
const candX = dragRef.current.startPanX - dx;
const candY = dragRef.current.startPanY - dy;
const next = { x: Math.max(0, Math.min(maxX, candX)), y: Math.max(0, Math.min(maxY, candY)) };
pagePanRef.current.set(page.pageNumber, next);
forceRerender(v => v + 1);
e.preventDefault();
}}
onMouseUp={(e) => {
if (dragRef.current.active) {
dragRef.current.active = false;
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
}
}}
onMouseLeave={(e) => {
if (dragRef.current.active) {
dragRef.current.active = false;
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
}
}}
>
<div
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
style={{
transform: `translate(${-panX}px, ${-panY}px) scale(${zoom})`,
transform: `scale(${innerScale}) translate(${-((pagePanRef.current.get(page.pageNumber)?.x || 0) / innerScale)}px, ${-((pagePanRef.current.get(page.pageNumber)?.y || 0) / innerScale)}px)`,
transformOrigin: 'top left'
}}
>
@ -301,7 +326,7 @@ const CompareDocumentPane = ({
</div>
</div>
</div>
</LazyLoadContainer>
</>
);
})}
</Stack>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Loader, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '@app/hooks/useIsMobile';
@ -265,6 +265,92 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
};
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
// Shared page navigation state/input
const maxSharedPages = useMemo(() => {
const baseMax = baseTotal || basePages.length || 0;
const compMax = compTotal || comparisonPages.length || 0;
const minKnown = Math.min(baseMax || Infinity, compMax || Infinity);
if (!Number.isFinite(minKnown)) return 0;
return Math.max(0, minKnown);
}, [baseTotal, compTotal, basePages.length, comparisonPages.length]);
const [pageInputValue, setPageInputValue] = useState<string>('1');
const typingTimerRef = useRef<number | null>(null);
const isTypingRef = useRef(false);
// Clamp the displayed input if max changes smaller than current
useEffect(() => {
if (!pageInputValue) return;
const n = Math.max(1, parseInt(pageInputValue, 10) || 1);
if (maxSharedPages > 0 && n > maxSharedPages) {
setPageInputValue(String(maxSharedPages));
}
}, [maxSharedPages]);
const scrollBothToPage = useCallback((pageNum: number) => {
const scrollOne = (container: HTMLDivElement | null) => {
if (!container) return false;
const pageEl = container.querySelector(`.compare-diff-page[data-page-number="${pageNum}"]`) as HTMLElement | null;
if (!pageEl) return false;
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
const desired = Math.max(0, Math.min(maxTop, pageEl.offsetTop - Math.round(container.clientHeight * 0.2)));
container.scrollTop = desired;
return true;
};
const hitBase = scrollOne(baseScrollRef.current);
const hitComp = scrollOne(comparisonScrollRef.current);
// Warn if one or both pages are not yet rendered
const baseHas = basePages.some(p => p.pageNumber === pageNum);
const compHas = comparisonPages.some(p => p.pageNumber === pageNum);
if (!baseHas || !compHas) {
alert({
alertType: 'warning',
title: t('compare.rendering.pageNotReadyTitle', 'Page not rendered yet'),
body: t('compare.rendering.pageNotReadyBody', 'Some pages are still rendering. Navigation will snap once they are ready.'),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: false,
durationMs: 2500,
});
}
return hitBase || hitComp;
}, [basePages, comparisonPages, baseScrollRef, comparisonScrollRef, t]);
const handleTypingChange = useCallback((next: string) => {
// Only digits; allow empty while editing
const digits = next.replace(/[^0-9]/g, '');
if (digits.length === 0) {
setPageInputValue('');
if (typingTimerRef.current != null) {
window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = null;
}
return;
}
const parsed = Math.max(1, parseInt(digits, 10));
const capped = maxSharedPages > 0 ? Math.min(parsed, maxSharedPages) : parsed;
const display = String(capped);
setPageInputValue(display);
isTypingRef.current = true;
if (typingTimerRef.current != null) window.clearTimeout(typingTimerRef.current);
typingTimerRef.current = window.setTimeout(() => {
isTypingRef.current = false;
scrollBothToPage(capped);
}, 300);
}, [maxSharedPages, scrollBothToPage]);
const handleVisiblePageChange = useCallback((pane: 'base' | 'comparison', page: number) => {
// Reflect scroll position in the input, but do not trigger navigation
if (isTypingRef.current) return; // ignore during typing debounce window
if (page <= 0) return;
const display = String(Math.min(maxSharedPages || page, page));
setPageInputValue(display);
}, [maxSharedPages]);
return (
<Stack className="compare-workbench">
@ -303,6 +389,10 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
documentLabel={baseDocumentLabel}
pageLabel={pageLabel}
altLabel={baseDocumentLabel}
pageInputValue={pageInputValue}
onPageInputChange={handleTypingChange}
maxSharedPages={maxSharedPages}
onVisiblePageChange={handleVisiblePageChange}
/>
<CompareDocumentPane
pane="comparison"
@ -335,6 +425,10 @@ const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
documentLabel={comparisonDocumentLabel}
pageLabel={pageLabel}
altLabel={comparisonDocumentLabel}
pageInputValue={pageInputValue}
onPageInputChange={handleTypingChange}
maxSharedPages={maxSharedPages}
onVisiblePageChange={handleVisiblePageChange}
/>
</div>
</Stack>

View File

@ -6,8 +6,57 @@ const DISPLAY_SCALE = 1;
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
// Simple shared cache so rendering progress can resume across unmounts/remounts
const previewCache: Map<string, { pages: PagePreview[]; total: number }> = new Map();
// Observable preview cache so rendering progress can resume across remounts and view switches
type CacheEntry = { pages: PagePreview[]; total: number; subscribers: Set<() => void> };
const previewCache: Map<string, CacheEntry> = new Map();
const latestVersionMap: Map<string, symbol> = new Map();
const getOrCreateEntry = (key: string): CacheEntry => {
let entry = previewCache.get(key);
if (!entry) {
entry = { pages: [], total: 0, subscribers: new Set() };
previewCache.set(key, entry);
}
return entry;
};
const notify = (entry: CacheEntry) => {
entry.subscribers.forEach((fn) => {
try { fn(); } catch { /* no-op */ }
});
};
const subscribe = (key: string, fn: () => void): (() => void) => {
const entry = getOrCreateEntry(key);
entry.subscribers.add(fn);
return () => entry.subscribers.delete(fn);
};
const appendBatchToCache = (key: string, batch: PagePreview[], provisionalTotal?: number) => {
const entry = getOrCreateEntry(key);
const next = entry.pages.slice();
for (const p of batch) {
const idx = next.findIndex((x) => x.pageNumber > p.pageNumber);
if (idx === -1) next.push(p); else next.splice(idx, 0, p);
}
entry.pages = next;
if (typeof provisionalTotal === 'number' && entry.total === 0) entry.total = provisionalTotal;
notify(entry);
};
const setTotalInCache = (key: string, total: number) => {
const entry = getOrCreateEntry(key);
entry.total = total;
notify(entry);
};
const replacePagesInCache = (key: string, pages: PagePreview[], total?: number) => {
const entry = getOrCreateEntry(key);
entry.pages = pages.slice();
if (typeof total === 'number') entry.total = total;
notify(entry);
};
const renderPdfDocumentToImages = async (
file: File,
@ -112,13 +161,16 @@ export const useComparePagePreviews = ({
}
const key = `${(file as any).name || 'file'}:${(file as any).size || 0}:${cacheKey ?? 'none'}`;
const cached = previewCache.get(key);
const cachedTotal = cached?.total ?? (cached?.pages.length ?? 0);
const refreshVersion = Symbol(key);
latestVersionMap.set(key, refreshVersion);
const entry = getOrCreateEntry(key);
const cachedTotal = entry.total ?? (entry.pages.length ?? 0);
let lastKnownTotal = cachedTotal;
const isFullyCached = Boolean(cached && cached.pages.length > 0 && cachedTotal > 0 && cached.pages.length >= cachedTotal);
const isFullyCached = Boolean(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal);
if (cached) {
setPages(cached.pages.slice());
if (entry.pages.length > 0) {
const nextPages = entry.pages.slice();
setPages(nextPages);
setTotalPages(cachedTotal);
} else {
setTotalPages(0);
@ -126,6 +178,14 @@ export const useComparePagePreviews = ({
setLoading(!isFullyCached);
const unsubscribe = subscribe(key, () => {
const e = getOrCreateEntry(key);
setPages(e.pages.slice());
setTotalPages(e.total);
const done = e.pages.length > 0 && e.total > 0 && e.pages.length >= e.total;
setLoading(!done);
});
if (isFullyCached) {
return () => {
cancelled = true;
@ -137,44 +197,35 @@ export const useComparePagePreviews = ({
try {
inFlightRef.current += 1;
const current = inFlightRef.current;
const startAt = (cached?.pages?.length ?? 0) + 1;
const startAt = (entry?.pages?.length ?? 0) + 1;
const previews = await renderPdfDocumentToImages(
file,
(batch) => {
if (cancelled || current !== inFlightRef.current) return;
// Stream batches into state
setPages((prev) => {
const next = [...prev];
for (const p of batch) {
const idx = next.findIndex((x) => x.pageNumber > p.pageNumber);
if (idx === -1) next.push(p); else next.splice(idx, 0, p);
}
// Update shared cache
previewCache.set(key, { pages: next, total: lastKnownTotal || cachedTotal });
return next;
});
appendBatchToCache(key, batch, lastKnownTotal || cachedTotal);
},
16,
(total) => {
if (!cancelled && current === inFlightRef.current) {
lastKnownTotal = total;
setTotalPages(total);
// Initialize or update cache record while preserving any pages
const existingPages = previewCache.get(key)?.pages ?? [];
previewCache.set(key, { pages: existingPages.slice(), total });
setTotalInCache(key, total);
}
},
startAt,
() => cancelled || current !== inFlightRef.current
);
if (!cancelled && current === inFlightRef.current) {
const cacheEntry = previewCache.get(key);
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry?.total || previews.length;
const stillLatest = latestVersionMap.get(key) === refreshVersion;
if (!stillLatest) {
return;
}
const cacheEntry = getOrCreateEntry(key);
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length;
lastKnownTotal = finalTotal;
const finalPages = cacheEntry ? cacheEntry.pages.slice() : previews.slice();
previewCache.set(key, { pages: finalPages.slice(), total: finalTotal });
setPages(finalPages);
setTotalPages(finalTotal);
const cachePages = cacheEntry.pages ?? [];
const preferPreviews = previews.length > cachePages.length;
const finalPages = preferPreviews ? previews.slice() : cachePages.slice();
replacePagesInCache(key, finalPages, finalTotal);
}
} catch (error) {
console.error('[compare] failed to render document preview', error);
@ -192,6 +243,7 @@ export const useComparePagePreviews = ({
return () => {
cancelled = true;
unsubscribe();
};
}, [file, enabled, cacheKey]);

View File

@ -504,30 +504,10 @@ export const useComparePanZoom = ({
}
}
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);
}
if (isBase) {
setBasePan(desired);
} else {
if (isBase) {
setBasePan(desired);
} else {
setComparisonPan(desired);
}
setComparisonPan(desired);
}
},
[getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations]
@ -761,30 +741,10 @@ export const useComparePanZoom = ({
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
}
}
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);
}
if (isBase) {
setBasePan(desired);
} else {
if (isBase) {
setBasePan(desired);
} else {
setComparisonPan(desired);
}
setComparisonPan(desired);
}
event.preventDefault();
}

View File

@ -126,11 +126,11 @@ export const useCompareRightRailButtons = ({
/>
),
tooltip: isScrollLinked
? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan')
: t('compare.actions.linkScrollPan', 'Link scroll and pan'),
? t('compare.actions.unlinkScroll', 'Unlink scroll')
: t('compare.actions.linkScroll', 'Link scroll'),
ariaLabel: isScrollLinked
? t('compare.actions.unlinkScrollPan', 'Unlink scroll and pan')
: t('compare.actions.linkScrollPan', 'Link scroll and pan'),
? t('compare.actions.unlinkScroll', 'Unlink scroll')
: t('compare.actions.linkScroll', 'Link scroll'),
section: 'top',
order: 15,
onClick: () => {
@ -141,8 +141,8 @@ export const useCompareRightRailButtons = ({
if (!isMobile) {
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.'),
title: t('compare.toasts.unlinkedTitle', 'Independent scroll enabled'),
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes when unlinked is off.'),
durationMs: 5000,
location: 'bottom-center' as ToastLocation,
expandable: false,

View File

@ -23,6 +23,7 @@ import type { StirlingFile } from '@app/types/fileContext';
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
import type { CompareWorkbenchData } from '@app/types/compare';
import FitText from '@app/components/shared/FitText';
import { getDefaultWorkbench } from '@app/types/workbench';
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
@ -36,7 +37,7 @@ const Compare = (props: BaseToolProps) => {
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const { selectors } = useFileContext();
const { selectors, actions: fileActions } = useFileContext();
const base = useBaseTool(
'compare',
@ -51,6 +52,7 @@ const Compare = (props: BaseToolProps) => {
const compareIcon = useMemo(() => <CompareRoundedIcon fontSize="small" />, []);
const [swapConfirmOpen, setSwapConfirmOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
useEffect(() => {
registerCustomWorkbenchView({
@ -120,23 +122,39 @@ const Compare = (props: BaseToolProps) => {
);
const prepareWorkbenchForRun = useCallback(
(baseId: FileId | null, compId: FileId | null) => {
(
baseId: FileId | null,
compId: FileId | null,
options?: { baseFile?: StirlingFile | null; comparisonFile?: StirlingFile | null }
) => {
if (!baseId || !compId) {
return;
}
const previous = lastWorkbenchDataRef.current;
const resolvedBaseFile =
options?.baseFile ??
(baseId ? selectors.getFile(baseId) : null) ??
previous?.baseLocalFile ??
null;
const resolvedComparisonFile =
options?.comparisonFile ??
(compId ? selectors.getFile(compId) : null) ??
previous?.comparisonLocalFile ??
null;
updateWorkbenchData({
result: null,
baseFileId: baseId,
comparisonFileId: compId,
baseLocalFile: lastWorkbenchDataRef.current?.baseLocalFile ?? null,
comparisonLocalFile: lastWorkbenchDataRef.current?.comparisonLocalFile ?? null,
baseLocalFile: resolvedBaseFile,
comparisonLocalFile: resolvedComparisonFile,
isLoading: true,
});
lastProcessedAtRef.current = null;
},
[operation.result, updateWorkbenchData]
[selectors, updateWorkbenchData]
);
useEffect(() => {
@ -160,12 +178,21 @@ const Compare = (props: BaseToolProps) => {
result.base.fileId === baseFileId &&
result.comparison.fileId === comparisonFileId
) {
const previous = lastWorkbenchDataRef.current;
const baseLocalFile =
(baseFileId ? selectors.getFile(baseFileId) : null) ??
previous?.baseLocalFile ??
null;
const comparisonLocalFile =
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
previous?.comparisonLocalFile ??
null;
updateWorkbenchData({
result,
baseFileId,
comparisonFileId,
baseLocalFile: null,
comparisonLocalFile: null,
baseLocalFile,
comparisonLocalFile,
isLoading: false,
});
lastProcessedAtRef.current = processedAt;
@ -173,12 +200,21 @@ const Compare = (props: BaseToolProps) => {
}
if (base.operation.isLoading) {
const previous = lastWorkbenchDataRef.current;
const baseLocalFile =
(baseFileId ? selectors.getFile(baseFileId) : null) ??
previous?.baseLocalFile ??
null;
const comparisonLocalFile =
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
previous?.comparisonLocalFile ??
null;
updateWorkbenchData({
result: null,
baseFileId,
comparisonFileId,
baseLocalFile: lastWorkbenchDataRef.current?.baseLocalFile ?? null,
comparisonLocalFile: lastWorkbenchDataRef.current?.comparisonLocalFile ?? null,
baseLocalFile,
comparisonLocalFile,
isLoading: true,
});
return;
@ -189,19 +225,24 @@ const Compare = (props: BaseToolProps) => {
operation.result,
params.baseFileId,
params.comparisonFileId,
selectors,
updateWorkbenchData,
]);
const handleExecuteCompare = useCallback(async () => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
const baseSel =
base.selectedFiles.find((file) => file.fileId === baseId) ??
(baseId ? selectors.getFile(baseId) : null);
const compSel =
base.selectedFiles.find((file) => file.fileId === compId) ??
(compId ? selectors.getFile(compId) : null);
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);
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
prepareWorkbenchForRun(baseId, compId);
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
if (baseId && compId) {
requestAnimationFrame(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
@ -212,19 +253,23 @@ const Compare = (props: BaseToolProps) => {
{ ...params },
selected
);
}, [navigationActions, operation, params, prepareWorkbenchForRun, selectors]);
}, [base.selectedFiles, navigationActions, operation, params, prepareWorkbenchForRun, 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;
const baseSel =
base.selectedFiles.find((file) => file.fileId === baseId) ??
(baseId ? selectors.getFile(baseId) : null);
const compSel =
base.selectedFiles.find((file) => file.fileId === compId) ??
(compId ? selectors.getFile(compId) : null);
if (baseSel) selected.push(baseSel);
if (compSel) selected.push(compSel);
prepareWorkbenchForRun(baseId, compId);
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
await operation.executeOperation(nextParams, selected);
}, [operation, params, prepareWorkbenchForRun, selectors]);
}, [base.selectedFiles, operation, params, prepareWorkbenchForRun, selectors]);
const performSwap = useCallback(() => {
const baseId = params.baseFileId as FileId | null;
@ -258,10 +303,7 @@ const Compare = (props: BaseToolProps) => {
if (!stub) {
return (
<Stack gap={6}>
<Text fw={700} size="sm">
{role === 'base' ? t('compare.original.label', 'Original PDF') : t('compare.edited.label', 'Edited PDF')}
</Text>
<Stack gap={6}>
<Box
style={{
border: '1px solid var(--border-default)',
@ -290,9 +332,6 @@ const Compare = (props: BaseToolProps) => {
return (
<Stack gap={6}>
<Text fw={700} size="sm">
{role === 'base' ? t('compare.original.label', 'Original PDF') : t('compare.edited.label', 'Edited PDF')}
</Text>
<Box
style={{
border: '1px solid var(--border-default)',
@ -339,6 +378,7 @@ const Compare = (props: BaseToolProps) => {
);
const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId);
const hasAnyFiles = selectors.getFiles().length > 0;
return createToolFlow({
files: {
@ -359,6 +399,25 @@ const Compare = (props: BaseToolProps) => {
width: '100%',
}}
>
{/* Header row: Original PDF + Clear selected aligned to swap column */}
<Box
style={{ gridColumn: hasBothSelected ? '1 / span 2' : '1', display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.5rem' }}
>
<Text fw={700} size="sm">{t('compare.original.label', 'Original PDF')}</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => setClearConfirmOpen(true)}
disabled={!hasAnyFiles}
styles={{ root: { textDecoration: 'underline' } }}
style={{
background: !hasAnyFiles ? 'transparent' : undefined,
color: !hasAnyFiles ? 'var(--spdf-clear-disabled-text)' : undefined
}}
>
{t('compare.clearSelected', 'Clear selected')}
</Button>
</Box>
<Box
style={{
gridColumn: '1',
@ -368,18 +427,19 @@ const Compare = (props: BaseToolProps) => {
>
{renderSelectedFile('base')}
<div style={{ height: '0.75rem' }} />
{/* Edited PDF section header */}
<Text fw={700} size="sm" style={{ marginBottom: '1rem', marginTop: '0.5rem'}}>{t('compare.edited.label', 'Edited PDF')}</Text>
{renderSelectedFile('comparison')}
</Box>
{hasBothSelected && (
<Box
style={{
gridColumn: '2',
gridRow: '1',
gridRow: '2',
display: 'flex',
alignItems: 'stretch',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'stretch',
marginTop: '1.5rem',
}}
>
<Button
@ -425,6 +485,34 @@ const Compare = (props: BaseToolProps) => {
</Group>
</Stack>
</Modal>
<Modal
opened={clearConfirmOpen}
onClose={() => setClearConfirmOpen(false)}
title={t('compare.clear.confirmTitle', 'Clear selected PDFs?')}
centered
size="sm"
>
<Stack gap="md">
<Text>{t('compare.clear.confirmBody', 'This will close the current comparison and take you back to Active Files.')}</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={() => setClearConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
<Button
variant="filled"
onClick={() => {
setClearConfirmOpen(false);
try { base.operation.cancelOperation(); } catch {}
try { base.operation.resetResults(); } catch {}
base.params.setParameters(prev => ({ ...prev, baseFileId: null, comparisonFileId: null }));
try { fileActions.clearSelections(); } catch {}
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
navigationActions.setWorkbench(getDefaultWorkbench());
}}
>
{t('compare.clear.confirm', 'Clear and return')}
</Button>
</Group>
</Stack>
</Modal>
</Box>
),
},

View File

@ -168,6 +168,12 @@ export interface CompareDocumentPaneProps {
documentLabel: string;
pageLabel: string;
altLabel: string;
// Page input/navigation props (optional to keep call sites flexible)
pageInputValue?: string;
onPageInputChange?: (next: string) => void;
maxSharedPages?: number; // min(baseTotal, compTotal)
renderedPageNumbers?: Set<number>;
onVisiblePageChange?: (pane: 'base' | 'comparison', pageNumber: number) => void;
}
// Import types that are referenced in CompareDocumentPaneProps