mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
fix bugs and style changes
This commit is contained in:
parent
d1f2ffaaf2
commit
51dac11183
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user