mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/v2/compare tool (#4751)
# Description of Changes - Addition of the compare tool - --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
f22f697edc
commit
a5e2b54274
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
@@ -64,7 +64,7 @@ const FileEditorThumbnail = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
|
||||
@@ -81,6 +81,7 @@ export default function Workbench() {
|
||||
|
||||
switch (currentView) {
|
||||
case "fileEditor":
|
||||
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
@@ -98,6 +99,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
case "viewer":
|
||||
|
||||
return (
|
||||
<Viewer
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
@@ -110,6 +112,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
case "pageEditor":
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageEditor
|
||||
@@ -143,6 +146,8 @@ export default function Workbench() {
|
||||
default:
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
|
||||
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
@@ -154,7 +159,7 @@ export default function Workbench() {
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
className="flex-1 h-full min-w-0 relative flex flex-col"
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
@@ -182,10 +187,11 @@ export default function Workbench() {
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
className={`flex-1 min-h-0 relative z-10 ${currentView === 'viewer' || !isBaseWorkbench(currentView) ? '' : styles.workbenchScrollable}`}
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined,
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Text, Checkbox } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
@@ -69,7 +69,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
@@ -15,10 +15,10 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const [active, setActive] = useState<NavKey>('general');
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState<NavKey>('general');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
|
||||
@@ -65,6 +65,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const clickPendingRef = useRef(false);
|
||||
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
// Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target
|
||||
const isDomNode = (value: unknown): value is Node =>
|
||||
typeof Node !== 'undefined' && value instanceof Node;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (openTimeoutRef.current) {
|
||||
clearTimeout(openTimeoutRef.current);
|
||||
@@ -103,9 +107,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
(e: MouseEvent) => {
|
||||
const tEl = tooltipRef.current;
|
||||
const trg = triggerRef.current;
|
||||
const target = e.target as Node | null;
|
||||
const insideTooltip = tEl && target && tEl.contains(target);
|
||||
const insideTrigger = trg && target && trg.contains(target);
|
||||
const target = e.target as unknown;
|
||||
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
|
||||
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
|
||||
|
||||
// If pinned: only close when clicking outside BOTH tooltip & trigger
|
||||
if (isPinned) {
|
||||
@@ -172,7 +176,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const related = e.relatedTarget as Node | null;
|
||||
|
||||
// Moving into the tooltip → keep open
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
@@ -236,7 +240,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
return;
|
||||
}
|
||||
@@ -258,7 +262,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleTooltipPointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
|
||||
@@ -168,7 +168,8 @@ const TopControls = ({
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
|
||||
|
||||
<SegmentedControl
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
|
||||
@@ -93,13 +93,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
progress,
|
||||
} as ToastInstance;
|
||||
|
||||
// Detect completion
|
||||
// Detect completion but do not auto-flip to success.
|
||||
// Callers (e.g., compare workbench) explicitly set alertType when done.
|
||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||
// On completion: finalize type as success unless explicitly provided otherwise
|
||||
next.justCompleted = false;
|
||||
if (!updates.alertType) {
|
||||
next.alertType = 'success';
|
||||
}
|
||||
next.justCompleted = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-center {
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
|
||||
@@ -8,6 +8,7 @@ const locationToClass: Record<ToastLocation, string> = {
|
||||
'top-right': 'toast-container--top-right',
|
||||
'bottom-left': 'toast-container--bottom-left',
|
||||
'bottom-right': 'toast-container--bottom-right',
|
||||
'bottom-center': 'toast-container--bottom-center',
|
||||
};
|
||||
|
||||
function getToastItemClass(t: ToastInstance): string {
|
||||
@@ -44,7 +45,7 @@ export default function ToastRenderer() {
|
||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ToastOptions } from '@app/components/toast/types';
|
||||
import { ToastApi, ToastInstance, ToastOptions } from '@app/components/toast/types';
|
||||
import { useToast, ToastProvider } from '@app/components/toast/ToastContext';
|
||||
import ToastRenderer from '@app/components/toast/ToastRenderer';
|
||||
|
||||
@@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer };
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
type ToastContextApi = ToastApi & { toasts: ToastInstance[] };
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
const subscribers: Array<(fn: ToastContextApi) => void> = [];
|
||||
let api: ToastContextApi | null = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
provide(instance: ToastContextApi) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
subscribers.splice(0).forEach(cb => cb(instance));
|
||||
},
|
||||
get(): ToastContextApi | null {
|
||||
return api;
|
||||
},
|
||||
onReady(cb: (readyApi: ToastContextApi) => void) {
|
||||
if (api) {
|
||||
cb(api);
|
||||
} else {
|
||||
subscribers.push(cb);
|
||||
}
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,4 +66,3 @@ export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'bottom-center';
|
||||
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||
|
||||
export interface ToastOptions {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import { ActionIcon, ScrollArea } from '@mantine/core';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface';
|
||||
@@ -26,7 +26,7 @@ export default function ToolPanel() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
leftPanelView,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare';
|
||||
import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare';
|
||||
import CompareNavigationDropdown from '@app/components/tools/compare/CompareNavigationDropdown';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
// utilities moved to compare.ts
|
||||
|
||||
const CompareDocumentPane = ({
|
||||
pane,
|
||||
layout,
|
||||
scrollRef,
|
||||
peerScrollRef,
|
||||
handleScrollSync,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPanMode,
|
||||
zoom,
|
||||
title,
|
||||
dropdownPlaceholder,
|
||||
changes,
|
||||
onNavigateChange,
|
||||
isLoading,
|
||||
processingMessage,
|
||||
pages,
|
||||
pairedPages,
|
||||
wordHighlightMap,
|
||||
metaIndexToGroupId,
|
||||
documentLabel,
|
||||
pageLabel,
|
||||
altLabel,
|
||||
onVisiblePageChange,
|
||||
}: CompareDocumentPaneProps) => {
|
||||
const isMobileViewport = useIsMobile();
|
||||
const pairedPageMap = useMemo(() => {
|
||||
const map = new Map<number, PagePreview>();
|
||||
pairedPages.forEach((item) => {
|
||||
map.set(item.pageNumber, item);
|
||||
});
|
||||
return map;
|
||||
}, [pairedPages]);
|
||||
|
||||
const HIGHLIGHT_BG_VAR = pane === 'base' ? 'var(--spdf-compare-removed-bg)' : 'var(--spdf-compare-added-bg)';
|
||||
const OFFSET_PIXELS = pane === 'base' ? 4 : 2;
|
||||
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
|
||||
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 visiblePageRafRef = useRef<number | null>(null);
|
||||
const lastReportedVisiblePageRef = useRef<number | null>(null);
|
||||
const pageNodesRef = useRef<HTMLElement[] | null>(null);
|
||||
const groupedRectsByPage = useMemo(() => {
|
||||
const out = new Map<number, Map<string, TokenBoundingBox[]>>();
|
||||
for (const p of pages) {
|
||||
const rects = wordHighlightMap.get(p.pageNumber) ?? [];
|
||||
out.set(p.pageNumber, groupWordRects(rects, metaIndexToGroupId, pane));
|
||||
}
|
||||
return out;
|
||||
}, [pages, wordHighlightMap, metaIndexToGroupId, pane]);
|
||||
|
||||
// When zoom returns to 1 (reset), clear per-page pan state so content is centered again
|
||||
useEffect(() => {
|
||||
if (zoom <= 1) {
|
||||
pagePanRef.current.clear();
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
return (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||
<CompareNavigationDropdown
|
||||
changes={changes}
|
||||
placeholder={dropdownPlaceholder ?? null}
|
||||
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
|
||||
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);
|
||||
// Notify parent about the currently visible page (throttled via rAF)
|
||||
if (visiblePageRafRef.current != null) return;
|
||||
if (!onVisiblePageChange || pages.length === 0) return;
|
||||
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;
|
||||
let nodes = pageNodesRef.current;
|
||||
if (!nodes || nodes.length !== pages.length) {
|
||||
nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[];
|
||||
pageNodesRef.current = nodes;
|
||||
}
|
||||
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' && bestPage !== lastReportedVisiblePageRef.current) {
|
||||
lastReportedVisiblePageRef.current = bestPage;
|
||||
onVisiblePageChange(pane, bestPage);
|
||||
}
|
||||
visiblePageRafRef.current = null;
|
||||
});
|
||||
}}
|
||||
onMouseDown={undefined}
|
||||
onMouseMove={undefined}
|
||||
onMouseUp={undefined}
|
||||
onMouseLeave={undefined}
|
||||
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
|
||||
onTouchStart={(event) => onTouchStart(pane, event)}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
className="compare-pane__scroll"
|
||||
style={{ cursor: cursorStyle }}
|
||||
>
|
||||
<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" />
|
||||
<Text size="sm">{processingMessage}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pages.map((page) => {
|
||||
const peerPage = pairedPageMap.get(page.pageNumber);
|
||||
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
||||
const metrics = computePageLayoutMetrics({
|
||||
page,
|
||||
peerPage: peerPage ?? null,
|
||||
layout,
|
||||
isMobileViewport,
|
||||
scrollRefWidth: scrollRef.current?.clientWidth ?? null,
|
||||
viewportWidth,
|
||||
zoom,
|
||||
offsetPixels: OFFSET_PIXELS,
|
||||
});
|
||||
|
||||
const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics;
|
||||
|
||||
// Compute clamped pan for current zoom so content always touches edges when in bounds
|
||||
const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
|
||||
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||
const maxPanX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||
const maxPanY = Math.max(0, contentHeight - Math.round(containerHeight));
|
||||
const clampedPanX = Math.max(0, Math.min(maxPanX, storedPan.x));
|
||||
const clampedPanY = Math.max(0, Math.min(maxPanY, storedPan.y));
|
||||
|
||||
const groupedRects = groupedRectsByPage.get(page.pageNumber) ?? new Map();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="compare-diff-page"
|
||||
data-page-number={page.pageNumber}
|
||||
style={{ minHeight: `${containerHeight}px` }}
|
||||
>
|
||||
<div
|
||||
className="compare-page-title"
|
||||
style={{ width: `${containerWidth}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={{ 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;
|
||||
// Clamp panning based on the actual rendered content size.
|
||||
// The inner layer is width/height of the container, then scaled by innerScale.
|
||||
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||
const maxX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||
const maxY = Math.max(0, contentHeight - Math.round(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);
|
||||
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: `scale(${innerScale}) translate3d(${-((clampedPanX) / innerScale)}px, ${-((clampedPanY) / innerScale)}px, 0)`,
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{/* Image layer */}
|
||||
<img
|
||||
src={page.url ?? ''}
|
||||
alt={altLabel}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="compare-diff-page__image"
|
||||
onLoad={() => {
|
||||
if (!imageLoadedRef.current.get(page.pageNumber)) {
|
||||
imageLoadedRef.current.set(page.pageNumber, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Overlay loader until the page image is loaded */}
|
||||
{!((imageLoadedRef.current.get(page.pageNumber) ?? false)) && (
|
||||
<div className="compare-page-loader-overlay">
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{[...groupedRects.entries()].flatMap(([id, rects]) =>
|
||||
mergeConnectedRects(rects).map((rect, index) => {
|
||||
const rotation = normalizeRotation(page.rotation);
|
||||
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: HIGHLIGHT_BG_VAR,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareDocumentPane;
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NavigationDropdownProps } from '@app/types/compare';
|
||||
|
||||
const CompareNavigationDropdown = ({
|
||||
changes,
|
||||
placeholder,
|
||||
className,
|
||||
onNavigate,
|
||||
renderedPageNumbers,
|
||||
}: NavigationDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const newLineLabel = t('compare.newLine', 'new-line');
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
// Cache scrollTop so we can restore on next open
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) scrollTopRef.current = viewport.scrollTop;
|
||||
setIsOpen(false);
|
||||
},
|
||||
onDropdownOpen: () => {
|
||||
setIsOpen(true);
|
||||
// Restore scrollTop after mount and rebuild offsets
|
||||
requestAnimationFrame(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) viewport.scrollTop = scrollTopRef.current;
|
||||
const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[];
|
||||
groupOffsetsRef.current = headers.map((el) => {
|
||||
const text = el.textContent || '';
|
||||
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
return { top: el.offsetTop, page };
|
||||
});
|
||||
// Update sticky label based on current scroll position
|
||||
handleScrollPos({ x: 0, y: scrollTopRef.current });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class.
|
||||
const rx =
|
||||
(() => {
|
||||
try {
|
||||
// Construct at runtime so old engines don’t fail parse-time
|
||||
return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u');
|
||||
} catch {
|
||||
// Fallback (no Unicode props): letters, digits, and common punctuation/symbols
|
||||
return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!rx.test(t)) return false;
|
||||
return t.length > 0;
|
||||
};
|
||||
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [stickyPage, setStickyPage] = useState<number | null>(null);
|
||||
const groupOffsetsRef = useRef<Array<{ top: number; page: number }>>([]);
|
||||
const scrollTopRef = useRef(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const normalizedChanges = useMemo(() => {
|
||||
// Helper to strip localized new-line marker occurrences from labels
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const stripNewLine = (s: string) =>
|
||||
s
|
||||
.replace(new RegExp(`\\b${esc(newLineLabel)}\\b`, 'gi'), ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const cleaned = changes
|
||||
.map((c) => ({ value: c.value, label: stripNewLine(sanitize(c.label)), pageNumber: c.pageNumber }))
|
||||
.filter((c) => isMeaningful(c.label) && c.label.length > 0 && c.label.toLowerCase() !== newLineLabel.toLowerCase());
|
||||
const q = sanitize(query).toLowerCase();
|
||||
if (!q) return cleaned;
|
||||
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
|
||||
}, [changes, query, newLineLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
// Build offsets for group headers whenever list changes while open
|
||||
if (!isOpen) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[];
|
||||
groupOffsetsRef.current = headers.map((el) => {
|
||||
const text = el.textContent || '';
|
||||
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
return { top: el.offsetTop, page };
|
||||
});
|
||||
// Update sticky based on current scroll position
|
||||
handleScrollPos({ x: 0, y: viewport.scrollTop });
|
||||
}, [normalizedChanges, isOpen]);
|
||||
|
||||
const handleScrollPos = ({ y }: { x: number; y: number }) => {
|
||||
const offsets = groupOffsetsRef.current;
|
||||
if (offsets.length === 0) return;
|
||||
// Find the last header whose top is <= scroll, so the next header replaces it
|
||||
let low = 0;
|
||||
let high = offsets.length - 1;
|
||||
let idx = 0;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary
|
||||
idx = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
const page = offsets[idx]?.page ?? offsets[0].page;
|
||||
if (page !== stickyPage) setStickyPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(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
|
||||
>
|
||||
<Combobox.Target>
|
||||
<div
|
||||
className={['compare-changes-select', className].filter(Boolean).join(' ')}
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
>
|
||||
<span className="compare-changes-select__placeholder">{placeholder}</span>
|
||||
<Combobox.Chevron />
|
||||
</div>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown className="compare-changes-dropdown">
|
||||
{/* Header sits outside scroll so it stays fixed at top */}
|
||||
<div>
|
||||
<Combobox.Search
|
||||
placeholder={t('compare.dropdown.searchPlaceholder', 'Search changes...')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Lazy render the scrollable content only when open */}
|
||||
{isOpen && (
|
||||
<div className="compare-dropdown-scrollwrap">
|
||||
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
|
||||
{stickyPage != null && (
|
||||
<div className="compare-dropdown-sticky" style={{ top: 0 }}>
|
||||
{t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage}
|
||||
{renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && (
|
||||
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Combobox.Options className="compare-dropdown-options">
|
||||
{normalizedChanges.length > 0 ? (
|
||||
(() => {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let lastPage: number | null = null;
|
||||
for (const item of normalizedChanges) {
|
||||
if (item.pageNumber && item.pageNumber !== lastPage) {
|
||||
lastPage = item.pageNumber;
|
||||
nodes.push(
|
||||
<div
|
||||
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
|
||||
key={`group-${lastPage}`}
|
||||
>
|
||||
{t('compare.summary.pageLabel', 'Page')}{' '}{lastPage}
|
||||
{renderedPageNumbers && !renderedPageNumbers.has(lastPage) && (
|
||||
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
<Combobox.Option
|
||||
value={item.value}
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
onNavigate(item.value, item.pageNumber);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<div className="compare-dropdown-option">
|
||||
<span className="compare-dropdown-option__text">{item.label}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
return nodes;
|
||||
})()
|
||||
) : (
|
||||
<Combobox.Empty>{t('compare.dropdown.noResults', 'No changes found')}</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</ScrollArea.Autosize>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareNavigationDropdown;
|
||||
@@ -0,0 +1,421 @@
|
||||
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';
|
||||
import {
|
||||
mapChangesForDropdown,
|
||||
getFileFromSelection,
|
||||
getStubFromSelection,
|
||||
computeShowProgressBanner,
|
||||
computeProgressPct,
|
||||
computeCountsText,
|
||||
computeMaxSharedPages,
|
||||
} from '@app/components/tools/compare/compare';
|
||||
import {
|
||||
CompareResultData,
|
||||
CompareWorkbenchData,
|
||||
} from '@app/types/compare';
|
||||
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||
import { useRightRailButtons } from '@app/hooks/useRightRailButtons';
|
||||
import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane';
|
||||
import { useComparePagePreviews } from '@app/components/tools/compare/hooks/useComparePagePreviews';
|
||||
import { useComparePanZoom } from '@app/components/tools/compare/hooks/useComparePanZoom';
|
||||
import { useCompareHighlights } from '@app/components/tools/compare/hooks/useCompareHighlights';
|
||||
import { useCompareChangeNavigation } from '@app/components/tools/compare/hooks/useCompareChangeNavigation';
|
||||
import '@app/components/tools/compare/compareView.css';
|
||||
import { useCompareRightRailButtons } from '@app/components/tools/compare/hooks/useCompareRightRailButtons';
|
||||
import { alert, updateToast, updateToastProgress, dismissToast } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
|
||||
interface CompareWorkbenchViewProps {
|
||||
data: CompareWorkbenchData | null;
|
||||
}
|
||||
|
||||
// helpers moved to compare.ts
|
||||
|
||||
const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const prefersStacked = useIsMobile();
|
||||
const { selectors } = useFileContext();
|
||||
|
||||
const result: CompareResultData | null = data?.result ?? null;
|
||||
const baseFileId = data?.baseFileId ?? null;
|
||||
const comparisonFileId = data?.comparisonFileId ?? null;
|
||||
const isOperationLoading = data?.isLoading ?? false;
|
||||
|
||||
const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors);
|
||||
const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors);
|
||||
const baseStub = getStubFromSelection(baseFileId, selectors);
|
||||
const comparisonStub = getStubFromSelection(comparisonFileId, selectors);
|
||||
|
||||
const processedAt = result?.totals.processedAt ?? null;
|
||||
|
||||
const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({
|
||||
file: baseFile,
|
||||
enabled: Boolean(result && baseFile),
|
||||
cacheKey: processedAt,
|
||||
});
|
||||
|
||||
const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({
|
||||
file: comparisonFile,
|
||||
enabled: Boolean(result && comparisonFile),
|
||||
cacheKey: processedAt,
|
||||
});
|
||||
|
||||
const {
|
||||
layout,
|
||||
toggleLayout,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
handleScrollSync,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
setBaseZoom,
|
||||
comparisonZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
setIsScrollLinked,
|
||||
isScrollLinked,
|
||||
zoomLimits,
|
||||
} = useComparePanZoom({
|
||||
basePages,
|
||||
comparisonPages,
|
||||
prefersStacked,
|
||||
});
|
||||
|
||||
const {
|
||||
baseWordChanges,
|
||||
comparisonWordChanges,
|
||||
metaIndexToGroupId,
|
||||
wordHighlightMaps,
|
||||
getRowHeightPx,
|
||||
} = useCompareHighlights(result, basePages, comparisonPages);
|
||||
|
||||
const temporarilySuppressScrollLink = useCallback((fn: () => void, durationMs = 700) => {
|
||||
const wasLinked = isScrollLinked;
|
||||
if (wasLinked) setIsScrollLinked(false);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
if (wasLinked) {
|
||||
// recapture anchors to keep panes aligned when relinking
|
||||
captureScrollLinkDelta();
|
||||
setIsScrollLinked(true);
|
||||
}
|
||||
}, Math.max(200, durationMs));
|
||||
}
|
||||
}, [isScrollLinked, setIsScrollLinked, captureScrollLinkDelta]);
|
||||
|
||||
const handleChangeNavigation = useCompareChangeNavigation(
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
{ temporarilySuppressScrollLink }
|
||||
);
|
||||
|
||||
const processingMessage = t('compare.status.processing', 'Analyzing differences...');
|
||||
const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document');
|
||||
const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document');
|
||||
const pageLabel = t('compare.summary.pageLabel', 'Page');
|
||||
|
||||
// Always show the selected file names from the sidebar; they are known before diff results
|
||||
const baseTitle = baseStub?.name || result?.base?.fileName || '';
|
||||
const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || '';
|
||||
|
||||
// During diff processing, show compact spinners in the dropdown badges
|
||||
const baseDropdownPlaceholder = (isOperationLoading || !result)
|
||||
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.deletionsLabel', 'Deletions')} <Loader size="xs" color="currentColor" /></span>)
|
||||
: t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length });
|
||||
const comparisonDropdownPlaceholder = (isOperationLoading || !result)
|
||||
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.additionsLabel', 'Additions')} <Loader size="xs" color="currentColor" /></span>)
|
||||
: t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length });
|
||||
|
||||
const rightRailButtons = useCompareRightRailButtons({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
});
|
||||
|
||||
useRightRailButtons(rightRailButtons);
|
||||
|
||||
// Rendering progress toast for very large PDFs
|
||||
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
||||
const totalsKnown = (baseTotal ?? 0) > 0 && (compTotal ?? 0) > 0;
|
||||
const showProgressBanner = useMemo(() => (
|
||||
computeShowProgressBanner(totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading, LARGE_PAGE_THRESHOLD)
|
||||
), [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]);
|
||||
|
||||
const progressPct = computeProgressPct(totalsKnown, baseTotal, compTotal, baseRendered, compRendered);
|
||||
|
||||
const progressToastIdRef = useRef<string | null>(null);
|
||||
const completionTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const allDone = useMemo(() => {
|
||||
const baseDone = (baseTotal || basePages.length) > 0 && baseRendered >= (baseTotal || basePages.length);
|
||||
const compDone = (compTotal || comparisonPages.length) > 0 && compRendered >= (compTotal || comparisonPages.length);
|
||||
return baseDone && compDone;
|
||||
}, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]);
|
||||
|
||||
// Drive toast lifecycle and progress updates
|
||||
useEffect(() => {
|
||||
// No toast needed
|
||||
if (!showProgressBanner) {
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const countsText = computeCountsText(
|
||||
baseRendered,
|
||||
baseTotal,
|
||||
basePages.length,
|
||||
compRendered,
|
||||
compTotal,
|
||||
comparisonPages.length,
|
||||
);
|
||||
if (!allDone) {
|
||||
// Create toast if missing
|
||||
if (!progressToastIdRef.current) {
|
||||
const id = alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
durationMs: 0,
|
||||
expandable: false,
|
||||
progressBarPercentage: progressPct,
|
||||
});
|
||||
progressToastIdRef.current = id;
|
||||
} else {
|
||||
updateToast(progressToastIdRef.current, {
|
||||
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
alertType: 'neutral', // ensure it stays neutral until completion
|
||||
});
|
||||
updateToastProgress(progressToastIdRef.current, progressPct);
|
||||
}
|
||||
} else {
|
||||
// Completed: update then auto-dismiss after 3s
|
||||
if (progressToastIdRef.current) {
|
||||
updateToast(progressToastIdRef.current, {
|
||||
title: t('compare.rendering.complete', 'Page rendering complete'),
|
||||
body: undefined,
|
||||
isPersistentPopup: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
updateToastProgress(progressToastIdRef.current, 100);
|
||||
if (completionTimerRef.current != null) window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = window.setTimeout(() => {
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
|
||||
|
||||
// Shared page navigation state/input
|
||||
const maxSharedPages = useMemo(() => (
|
||||
computeMaxSharedPages(baseTotal, compTotal, basePages.length, comparisonPages.length)
|
||||
), [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]);
|
||||
|
||||
return (
|
||||
<Stack className="compare-workbench">
|
||||
|
||||
<Stack gap="lg" className="compare-workbench__content">
|
||||
<div
|
||||
className={`compare-workbench__columns ${layout === 'stacked' ? 'compare-workbench__columns--stacked' : ''}`}
|
||||
>
|
||||
<CompareDocumentPane
|
||||
pane="base"
|
||||
layout={layout}
|
||||
scrollRef={baseScrollRef}
|
||||
peerScrollRef={comparisonScrollRef}
|
||||
handleScrollSync={handleScrollSync}
|
||||
handleWheelZoom={handleWheelZoom}
|
||||
handleWheelOverscroll={handleWheelOverscroll}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
isPanMode={isPanMode}
|
||||
zoom={baseZoom}
|
||||
title={baseTitle}
|
||||
dropdownPlaceholder={baseDropdownPlaceholder}
|
||||
changes={mapChangesForDropdown(baseWordChanges)}
|
||||
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)}
|
||||
isLoading={isOperationLoading || baseLoading}
|
||||
processingMessage={processingMessage}
|
||||
pages={basePages}
|
||||
pairedPages={comparisonPages}
|
||||
getRowHeightPx={getRowHeightPx}
|
||||
wordHighlightMap={wordHighlightMaps.base}
|
||||
metaIndexToGroupId={metaIndexToGroupId.base}
|
||||
documentLabel={baseDocumentLabel}
|
||||
pageLabel={pageLabel}
|
||||
altLabel={baseDocumentLabel}
|
||||
pageInputValue={pageInputValue}
|
||||
onPageInputChange={handleTypingChange}
|
||||
maxSharedPages={maxSharedPages}
|
||||
/>
|
||||
<CompareDocumentPane
|
||||
pane="comparison"
|
||||
layout={layout}
|
||||
scrollRef={comparisonScrollRef}
|
||||
peerScrollRef={baseScrollRef}
|
||||
handleScrollSync={handleScrollSync}
|
||||
handleWheelZoom={handleWheelZoom}
|
||||
handleWheelOverscroll={handleWheelOverscroll}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
isPanMode={isPanMode}
|
||||
zoom={comparisonZoom}
|
||||
title={comparisonTitle}
|
||||
dropdownPlaceholder={comparisonDropdownPlaceholder}
|
||||
changes={mapChangesForDropdown(comparisonWordChanges)}
|
||||
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)}
|
||||
isLoading={isOperationLoading || comparisonLoading}
|
||||
processingMessage={processingMessage}
|
||||
pages={comparisonPages}
|
||||
pairedPages={basePages}
|
||||
getRowHeightPx={getRowHeightPx}
|
||||
wordHighlightMap={wordHighlightMaps.comparison}
|
||||
metaIndexToGroupId={metaIndexToGroupId.comparison}
|
||||
documentLabel={comparisonDocumentLabel}
|
||||
pageLabel={pageLabel}
|
||||
altLabel={comparisonDocumentLabel}
|
||||
pageInputValue={pageInputValue}
|
||||
onPageInputChange={handleTypingChange}
|
||||
maxSharedPages={maxSharedPages}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareWorkbenchView;
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare';
|
||||
import type { FileId } from '@app/types/file';
|
||||
import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
|
||||
/** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */
|
||||
export const toRgba = (hexColor: string, alpha: number): string => {
|
||||
const hex = hexColor.replace('#', '');
|
||||
if (hex.length !== 6) return hexColor;
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
/** Normalize rotation to [0, 360). */
|
||||
export const normalizeRotation = (deg: number | undefined | null): number => {
|
||||
const n = ((deg ?? 0) % 360 + 360) % 360;
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge overlapping or touching rectangles into larger non-overlapping blocks.
|
||||
* Robust across rotations (vertical groups) and prevents dark spots from overlaps.
|
||||
*/
|
||||
export const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
|
||||
if (rects.length === 0) return rects;
|
||||
const EPS = 0.004; // small tolerance in normalized page coords
|
||||
const sorted = rects
|
||||
.slice()
|
||||
.sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
|
||||
const merged: TokenBoundingBox[] = [];
|
||||
|
||||
const overlapsOrTouches = (a: TokenBoundingBox, b: TokenBoundingBox) => {
|
||||
const aR = a.left + a.width;
|
||||
const aB = a.top + a.height;
|
||||
const bR = b.left + b.width;
|
||||
const bB = b.top + b.height;
|
||||
return !(b.left > aR + EPS || bR < a.left - EPS || b.top > aB + EPS || bB < a.top - EPS);
|
||||
};
|
||||
|
||||
for (const r of sorted) {
|
||||
let mergedIntoExisting = false;
|
||||
for (let i = 0; i < merged.length; i += 1) {
|
||||
const m = merged[i];
|
||||
if (overlapsOrTouches(m, r)) {
|
||||
const left = Math.min(m.left, r.left);
|
||||
const top = Math.min(m.top, r.top);
|
||||
const right = Math.max(m.left + m.width, r.left + r.width);
|
||||
const bottom = Math.max(m.top + m.height, r.top + r.height);
|
||||
merged[i] = {
|
||||
left,
|
||||
top,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
mergedIntoExisting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mergedIntoExisting) merged.push({ ...r });
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
/** Group word rectangles by change id using metaIndexToGroupId. */
|
||||
export const groupWordRects = (
|
||||
wordRects: WordHighlightEntry[],
|
||||
metaIndexToGroupId: Map<number, string>,
|
||||
pane: 'base' | 'comparison'
|
||||
): Map<string, TokenBoundingBox[]> => {
|
||||
const groupedRects = new Map<string, TokenBoundingBox[]>();
|
||||
for (const { rect, metaIndex } of wordRects) {
|
||||
const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`;
|
||||
const current = groupedRects.get(id) ?? [];
|
||||
current.push(rect);
|
||||
groupedRects.set(id, current);
|
||||
}
|
||||
return groupedRects;
|
||||
};
|
||||
|
||||
/** Compute derived layout metrics for a page render, given environment and zoom. */
|
||||
export const computePageLayoutMetrics = (args: {
|
||||
page: PagePreview;
|
||||
peerPage?: PagePreview | null;
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
isMobileViewport: boolean;
|
||||
scrollRefWidth: number | null;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
offsetPixels: number; // highlight offset in px relative to original page height
|
||||
}) => {
|
||||
const { page, peerPage, layout, isMobileViewport, scrollRefWidth, viewportWidth, zoom, offsetPixels } = args;
|
||||
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
|
||||
const fit = targetHeight / page.height;
|
||||
const highlightOffset = offsetPixels / page.height;
|
||||
const rotationNorm = normalizeRotation(page.rotation);
|
||||
const isPortrait = rotationNorm === 0 || rotationNorm === 180;
|
||||
const isStackedPortrait = layout === 'stacked' && isPortrait;
|
||||
|
||||
const containerW = scrollRefWidth ?? viewportWidth;
|
||||
const stackedWidth = isMobileViewport
|
||||
? Math.max(320, Math.round(containerW))
|
||||
: Math.max(320, Math.round(viewportWidth * 0.5));
|
||||
const stackedHeight = Math.round(stackedWidth * 1.4142);
|
||||
|
||||
const baseWidth = isStackedPortrait ? stackedWidth : Math.round(page.width * fit);
|
||||
const baseHeight = isStackedPortrait ? stackedHeight : Math.round(targetHeight);
|
||||
const containerMaxW = scrollRefWidth ?? viewportWidth;
|
||||
|
||||
// Container-first zooming with a stable baseline:
|
||||
// Treat zoom=1 as "fit to available width" for the page's base size so
|
||||
// the initial render is fully visible and centered (no cropping), regardless
|
||||
// of rotation or pane/container width. When zoom < 1, shrink the container;
|
||||
// when zoom > 1, keep the container at fit width and scale inner content.
|
||||
const MIN_CONTAINER_WIDTH = 120;
|
||||
const minScaleByWidth = MIN_CONTAINER_WIDTH / Math.max(1, baseWidth);
|
||||
const fitScaleByContainer = containerMaxW / Math.max(1, baseWidth);
|
||||
// Effective baseline scale used at zoom=1 (ensures at least the min width)
|
||||
const baselineContainerScale = Math.max(minScaleByWidth, fitScaleByContainer);
|
||||
// Lower bound the zoom so interactions remain stable
|
||||
const desiredZoom = Math.max(0.1, zoom);
|
||||
|
||||
let containerScale: number;
|
||||
let innerScale: number;
|
||||
if (desiredZoom >= 1) {
|
||||
// At or above baseline: keep container at fit width and scale inner content
|
||||
containerScale = baselineContainerScale;
|
||||
innerScale = +Math.max(0.1, desiredZoom).toFixed(4);
|
||||
} else {
|
||||
// Below baseline: shrink container proportionally, do not upscale inner
|
||||
const scaled = baselineContainerScale * desiredZoom;
|
||||
// Never smaller than minimum readable width
|
||||
containerScale = Math.max(minScaleByWidth, scaled);
|
||||
innerScale = 1;
|
||||
}
|
||||
|
||||
const containerWidth = Math.max(
|
||||
MIN_CONTAINER_WIDTH,
|
||||
Math.min(containerMaxW, Math.round(baseWidth * containerScale))
|
||||
);
|
||||
const containerHeight = Math.round(baseHeight * (containerWidth / Math.max(1, baseWidth)));
|
||||
|
||||
return {
|
||||
targetHeight,
|
||||
fit,
|
||||
highlightOffset,
|
||||
rotationNorm,
|
||||
isPortrait,
|
||||
isStackedPortrait,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
containerMaxW,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
innerScale,
|
||||
};
|
||||
};
|
||||
|
||||
/** Map changes to dropdown options tuple. */
|
||||
export const mapChangesForDropdown = (
|
||||
changes: Array<{ value: string; label: string; pageNumber: number }>
|
||||
) => changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber }));
|
||||
|
||||
/** File selection helpers */
|
||||
export const getFileFromSelection = (
|
||||
explicit: StirlingFile | null | undefined,
|
||||
fileId: FileId | null,
|
||||
selectors: { getFile: (id: FileId) => StirlingFile | undefined | null }
|
||||
): StirlingFile | null => {
|
||||
if (explicit) return explicit;
|
||||
if (!fileId) return null;
|
||||
return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null;
|
||||
};
|
||||
|
||||
export const getStubFromSelection = (
|
||||
fileId: FileId | null,
|
||||
selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined }
|
||||
): StirlingFileStub | null => {
|
||||
if (!fileId) return null;
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
return stub ?? null;
|
||||
};
|
||||
|
||||
/** Progress banner computations */
|
||||
export const computeShowProgressBanner = (
|
||||
totalsKnown: boolean,
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseLoading: boolean,
|
||||
compLoading: boolean,
|
||||
threshold: number = 400
|
||||
): boolean => {
|
||||
if (!totalsKnown) return false;
|
||||
const totals = [baseTotal ?? 0, compTotal ?? 0];
|
||||
return Math.max(...totals) >= threshold && (baseLoading || compLoading);
|
||||
};
|
||||
|
||||
export const computeProgressPct = (
|
||||
totalsKnown: boolean,
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseRendered: number,
|
||||
compRendered: number
|
||||
): number => {
|
||||
const totalCombined = totalsKnown ? ((baseTotal ?? 0) + (compTotal ?? 0)) : 0;
|
||||
const renderedCombined = baseRendered + compRendered;
|
||||
return totalsKnown && totalCombined > 0
|
||||
? Math.min(100, Math.round((renderedCombined / totalCombined) * 100))
|
||||
: 0;
|
||||
};
|
||||
|
||||
export const computeCountsText = (
|
||||
baseRendered: number,
|
||||
baseTotal: number | null | undefined,
|
||||
baseLength: number,
|
||||
compRendered: number,
|
||||
compTotal: number | null | undefined,
|
||||
compLength: number
|
||||
): string => {
|
||||
const baseTotalShown = baseTotal || baseLength;
|
||||
const compTotalShown = compTotal || compLength;
|
||||
return `${baseRendered}/${baseTotalShown} • ${compRendered}/${compTotalShown}`;
|
||||
};
|
||||
|
||||
export const computeMaxSharedPages = (
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseLen: number,
|
||||
compLen: number
|
||||
): number => {
|
||||
const baseMax = baseTotal || baseLen || 0;
|
||||
const compMax = compTotal || compLen || 0;
|
||||
const minKnown = Math.min(baseMax || Infinity, compMax || Infinity);
|
||||
if (!Number.isFinite(minKnown)) return 0;
|
||||
return Math.max(0, minKnown);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
.compare-dropdown-scrollwrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-dropdown-sticky {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .compare-dropdown-sticky {
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.compare-workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-workbench__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compare-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compare-pane__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.compare-pane__content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-workbench__mode {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.compare-workbench__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked mode: two rows with synchronized scroll panes */
|
||||
.compare-workbench__columns--stacked {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
.compare-workbench__columns > div {
|
||||
/* Critical for responsive flex children inside non-wrapping layouts */
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-legend {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sticky header styling overrides */
|
||||
.compare-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-toolbar);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem -0.5rem 0.5rem -0.5rem;
|
||||
}
|
||||
|
||||
/* Dropdown badge-like style - only style the dropdowns, not titles */
|
||||
.compare-changes-select {
|
||||
background: var(--spdf-compare-removed-badge-bg) !important;
|
||||
color: var(--spdf-compare-removed-badge-fg) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.compare-changes-select__placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.compare-changes-select__placeholder .mantine-Loader-root {
|
||||
display: inline-flex;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison {
|
||||
background: var(--spdf-compare-added-badge-bg) !important;
|
||||
color: var(--spdf-compare-added-badge-fg) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Wider dropdown menu for long block text */
|
||||
.compare-changes-dropdown {
|
||||
min-width: 520px !important;
|
||||
max-width: 70vw !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden; /* prevent inner elements from overflowing rounded edges */
|
||||
border-radius: 8px !important; /* match dropdown container radius */
|
||||
}
|
||||
|
||||
/* Ensure options text uses full width inside wider dropdown */
|
||||
.compare-dropdown-option__text {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Remove default padding inside Mantine options list so sticky header touches edges */
|
||||
.compare-changes-dropdown .mantine-Combobox-options {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.compare-dropdown-scrollwrap { margin: 0; }
|
||||
|
||||
/* Ensure search field sits flush and does not overflow */
|
||||
.compare-changes-dropdown .mantine-Combobox-search {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-top-left-radius: 8px !important;
|
||||
border-top-right-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown container */
|
||||
.compare-changes-select .mantine-Combobox-dropdown {
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
background-color: var(--bg-surface) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-dropdown {
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
background-color: var(--bg-surface) !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for ScrollArea */
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-muted) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-muted) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown options */
|
||||
.compare-changes-select .mantine-Combobox-option {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-option {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-Combobox-option:hover {
|
||||
background-color: var(--spdf-compare-removed-badge-bg) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-option:hover {
|
||||
background-color: var(--spdf-compare-added-badge-bg) !important;
|
||||
}
|
||||
|
||||
/* Style the search input */
|
||||
.compare-changes-select .mantine-Combobox-search {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-search {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-Combobox-search::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-search::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Style the chevron - ensure proper coloring */
|
||||
.compare-changes-select .mantine-Combobox-chevron,
|
||||
.compare-changes-select--comparison .mantine-Combobox-chevron {
|
||||
color: inherit !important;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Flash/pulse highlight for navigated change */
|
||||
@keyframes compare-flash {
|
||||
0% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||
background-color: rgba(255, 235, 59, 0.2) !important;
|
||||
}
|
||||
25% {
|
||||
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||
box-shadow: 0 0 20px rgba(255, 235, 59, 0.8);
|
||||
background-color: rgba(255, 235, 59, 0.4) !important;
|
||||
}
|
||||
50% {
|
||||
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||
box-shadow: 0 0 30px rgba(255, 235, 59, 0.9);
|
||||
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||
}
|
||||
75% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.8);
|
||||
box-shadow: 0 0 15px rgba(255, 235, 59, 0.6);
|
||||
background-color: rgba(255, 235, 59, 0.3) !important;
|
||||
}
|
||||
100% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||
background-color: rgba(255, 235, 59, 0.0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.compare-diff-highlight--flash {
|
||||
animation: compare-flash 1.5s ease-in-out 1;
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
/* Bonus: temporarily override red/green to yellow during flash for clarity */
|
||||
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Union overlay for group flash */
|
||||
.compare-diff-flash-overlay {
|
||||
animation: compare-flash 1.5s ease-in-out 1;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 235, 59, 0.4);
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.compare-legend__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.compare-legend__swatch {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.compare-summary__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compare-summary__stat-card {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.compare-summary__segment {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
.compare-diff-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.compare-diff-page__canvas {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-surface);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Center canvas in stacked portrait mode (width/height set inline) */
|
||||
.compare-diff-page__canvas[style*="margin-left: auto"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.compare-diff-page__canvas--zoom {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.compare-diff-page__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100%;
|
||||
background-color: #fff; /* ensure stable white backing during load */
|
||||
border: 1px solid var(--border-subtle);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.compare-diff-page__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Centered per-page title wrapper */
|
||||
.compare-page-title {
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.compare-page-title .mantine-Text-root {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
}
|
||||
|
||||
/* Overlay loader to avoid flash when image not yet loaded */
|
||||
.compare-page-loader-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.compare-diff-highlight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Compare dropdown option formatting (page + clamped text) */
|
||||
.compare-dropdown-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.compare-dropdown-option__page {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.compare-dropdown-option__text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
/* Non-sticky in-flow group headers; sticky handled by floating header */
|
||||
.compare-dropdown-group {
|
||||
position: static;
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.compare-dropdown-group.compare-dropdown-group--hidden {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .compare-dropdown-group {
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Light grey rendering flag next to page labels in the dropdown */
|
||||
.compare-dropdown-rendering-flag {
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Inline paragraph highlights in summary */
|
||||
.compare-inline {
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.05rem 0.15rem;
|
||||
}
|
||||
.compare-inline--removed {
|
||||
background-color: var(--spdf-compare-inline-removed-bg);
|
||||
}
|
||||
.compare-inline--added {
|
||||
background-color: var(--spdf-compare-inline-added-bg);
|
||||
}
|
||||
|
||||
.compare-pane-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg-background);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */
|
||||
.compare-tool__thumbnail {
|
||||
width: 4rem;
|
||||
height: 5.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.compare-tool__details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mobile: remove side margins and let canvases take full width inside column */
|
||||
@media (max-width: 768px) {
|
||||
.compare-workbench__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.compare-diff-page__canvas.compare-diff-page__canvas--zoom {
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.compare-diff-page__inner {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
type SuppressOptions = {
|
||||
temporarilySuppressScrollLink?: (fn: () => void, durationMs?: number) => void;
|
||||
};
|
||||
|
||||
export const useCompareChangeNavigation = (
|
||||
baseScrollRef: RefObject<HTMLDivElement | null>,
|
||||
comparisonScrollRef: RefObject<HTMLDivElement | null>,
|
||||
options?: SuppressOptions,
|
||||
) => {
|
||||
return useCallback(
|
||||
(changeValue: string, pane: Pane, pageNumber?: number) => {
|
||||
const suppress = <T extends void>(fn: () => T) => {
|
||||
if (options?.temporarilySuppressScrollLink) {
|
||||
options.temporarilySuppressScrollLink(fn, 700);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollPeerPageIfPossible = () => {
|
||||
if (!pageNumber) return;
|
||||
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||
const peer = peerRef.current;
|
||||
if (!peer) return;
|
||||
const peerPageEl = peer.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!peerPageEl) return;
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
peerMaxTop,
|
||||
peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)
|
||||
)
|
||||
);
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top, behavior: 'auto' });
|
||||
});
|
||||
};
|
||||
|
||||
const proceedWithNodes = (nodes: HTMLElement[]) => {
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
// Prefer a percent-in-page based vertical scroll, which is resilient to transforms.
|
||||
const anchor = nodes[0];
|
||||
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||
const inner = anchor.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||
if (pageEl && inner && !Number.isNaN(topPercent)) {
|
||||
const innerRect = inner.getBoundingClientRect();
|
||||
const innerHeight = Math.max(1, innerRect.height);
|
||||
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desiredTop = Math.max(
|
||||
0,
|
||||
Math.min(maxTop, pageEl.offsetTop + absoluteTopInPage - container.clientHeight / 2)
|
||||
);
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: desiredTop, behavior: 'auto' });
|
||||
});
|
||||
} else {
|
||||
// Fallback to bounding-rect based centering if percent approach is unavailable.
|
||||
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 maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desiredTop = Math.max(0, Math.min(maxTop, absoluteTop - (container.clientHeight - boxHeight) / 2));
|
||||
const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2);
|
||||
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
|
||||
// Also scroll the peer container to the corresponding location in the
|
||||
// other PDF (same page and approximate vertical position within page),
|
||||
// not just the same list/scroll position.
|
||||
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||
const peer = peerRef.current;
|
||||
if (peer) {
|
||||
// Use the first node as the anchor
|
||||
const anchor = nodes[0];
|
||||
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||
const pageNumAttr = pageEl?.getAttribute('data-page-number');
|
||||
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||
if (pageNumAttr) {
|
||||
const peerPageEl = peer.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumAttr}"]`
|
||||
) as HTMLElement | null;
|
||||
const peerInner = peerPageEl?.querySelector('.compare-diff-page__inner') as HTMLElement | null;
|
||||
if (peerPageEl && peerInner) {
|
||||
const innerRect = peerInner.getBoundingClientRect();
|
||||
const innerHeight = Math.max(1, innerRect.height);
|
||||
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const peerDesiredTop = Math.max(
|
||||
0,
|
||||
Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2)
|
||||
);
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top: peerDesiredTop, behavior: 'auto' });
|
||||
});
|
||||
} else if (peerPageEl) {
|
||||
// Fallback: Scroll to page top (clamped)
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const top = Math.max(0, Math.min(peerMaxTop, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)));
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
const nodes = findNodes();
|
||||
if (nodes.length > 0) {
|
||||
proceedWithNodes(nodes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Page-level fallback immediately so the user sees something happen
|
||||
const scrolledPage = scrollToPageIfNeeded();
|
||||
if (scrolledPage) {
|
||||
scrollPeerPageIfPossible();
|
||||
} else {
|
||||
// Even if the page element is not present yet, try to nudge peer pane
|
||||
scrollPeerPageIfPossible();
|
||||
}
|
||||
|
||||
// Wait for highlights to mount (pages/images render progressively)
|
||||
let settled = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (settled) return;
|
||||
const n = findNodes();
|
||||
if (n.length > 0) {
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
proceedWithNodes(n);
|
||||
}
|
||||
});
|
||||
try {
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
// Safety timeout to stop waiting after a while
|
||||
window.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
// We already scrolled to the page above; nothing else to do.
|
||||
}, 5000);
|
||||
},
|
||||
[baseScrollRef, comparisonScrollRef]
|
||||
);
|
||||
};
|
||||
|
||||
export type UseCompareChangeNavigationReturn = ReturnType<typeof useCompareChangeNavigation>;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
CompareFilteredTokenInfo,
|
||||
WordHighlightEntry,
|
||||
CompareResultData,
|
||||
CompareChangeOption,
|
||||
PagePreview,
|
||||
} from '@app/types/compare';
|
||||
|
||||
interface MetaGroupMap {
|
||||
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[];
|
||||
metaIndexToGroupId: MetaGroupMap;
|
||||
wordHighlightMaps: WordHighlightMaps;
|
||||
getRowHeightPx: (pageNumber: number) => number;
|
||||
}
|
||||
|
||||
const buildWordChanges = (
|
||||
tokens: CompareFilteredTokenInfo[],
|
||||
metaIndexToGroupId: Map<number, string>,
|
||||
groupPrefix: string
|
||||
): CompareChangeOption[] => {
|
||||
metaIndexToGroupId.clear();
|
||||
if (!tokens.length) return [];
|
||||
|
||||
const items: CompareChangeOption[] = [];
|
||||
let currentRun: CompareFilteredTokenInfo[] = [];
|
||||
|
||||
const flushRun = () => {
|
||||
if (currentRun.length === 0) return;
|
||||
const label = currentRun.map((token) => token.token).join(' ').trim();
|
||||
if (label.length === 0) {
|
||||
currentRun = [];
|
||||
return;
|
||||
}
|
||||
const first = currentRun[0];
|
||||
const last = currentRun[currentRun.length - 1];
|
||||
const groupId = `${groupPrefix}-t${first.metaIndex}-t${last.metaIndex}`;
|
||||
currentRun.forEach((token) => {
|
||||
metaIndexToGroupId.set(token.metaIndex, groupId);
|
||||
});
|
||||
const pageNumber = first.page ?? last.page ?? 1;
|
||||
items.push({ value: groupId, label, pageNumber });
|
||||
currentRun = [];
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.hasHighlight && token.bbox) {
|
||||
currentRun.push(token);
|
||||
} else {
|
||||
flushRun();
|
||||
}
|
||||
}
|
||||
flushRun();
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const buildHighlightMap = (
|
||||
tokens: CompareFilteredTokenInfo[]
|
||||
): Map<number, WordHighlightEntry[]> => {
|
||||
const map = new Map<number, WordHighlightEntry[]>();
|
||||
for (const token of tokens) {
|
||||
if (!token.hasHighlight || !token.bbox || token.page == null) continue;
|
||||
const list = map.get(token.page) ?? [];
|
||||
list.push({ rect: token.bbox, metaIndex: token.metaIndex });
|
||||
map.set(token.page, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const useCompareHighlights = (
|
||||
result: CompareResultData | null,
|
||||
basePages: PagePreview[],
|
||||
comparisonPages: PagePreview[],
|
||||
): UseCompareHighlightsResult => {
|
||||
const baseMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
const comparisonMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
|
||||
const baseWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.filteredTokenData.base,
|
||||
baseMetaIndexToGroupId,
|
||||
'base-group'
|
||||
);
|
||||
}, [baseMetaIndexToGroupId, result]);
|
||||
|
||||
const comparisonWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.filteredTokenData.comparison,
|
||||
comparisonMetaIndexToGroupId,
|
||||
'comparison-group'
|
||||
);
|
||||
}, [comparisonMetaIndexToGroupId, result]);
|
||||
|
||||
const wordHighlightMaps = useMemo(() => {
|
||||
if (!result) {
|
||||
return {
|
||||
base: new Map<number, WordHighlightEntry[]>(),
|
||||
comparison: new Map<number, WordHighlightEntry[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
base: buildHighlightMap(result.filteredTokenData.base),
|
||||
comparison: buildHighlightMap(result.filteredTokenData.comparison),
|
||||
};
|
||||
}, [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,
|
||||
metaIndexToGroupId: {
|
||||
base: baseMetaIndexToGroupId,
|
||||
comparison: comparisonMetaIndexToGroupId,
|
||||
},
|
||||
wordHighlightMaps,
|
||||
getRowHeightPx,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
|
||||
const DISPLAY_SCALE = 1;
|
||||
|
||||
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||
|
||||
// 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,
|
||||
onBatch?: (previews: PagePreview[]) => void,
|
||||
batchSize: number = 12,
|
||||
onInitTotal?: (totalPages: number) => void,
|
||||
startAtPage: number = 1,
|
||||
shouldAbort?: () => boolean,
|
||||
): 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));
|
||||
onInitTotal?.(pdf.numPages);
|
||||
|
||||
let batch: PagePreview[] = [];
|
||||
const shouldStop = () => Boolean(shouldAbort?.());
|
||||
|
||||
for (let pageNumber = Math.max(1, startAtPage); pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
if (shouldStop()) break;
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||
if (shouldStop()) break;
|
||||
|
||||
const preview: PagePreview = {
|
||||
pageNumber,
|
||||
width: Math.round(displayViewport.width),
|
||||
height: Math.round(displayViewport.height),
|
||||
rotation: (page.rotate || 0) % 360,
|
||||
url: canvas.toDataURL(),
|
||||
};
|
||||
previews.push(preview);
|
||||
if (onBatch) {
|
||||
batch.push(preview);
|
||||
if (batch.length >= batchSize) {
|
||||
onBatch(batch);
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
page.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
if (shouldStop()) break;
|
||||
}
|
||||
|
||||
if (onBatch && batch.length > 0) onBatch(batch);
|
||||
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);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const inFlightRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!file || !enabled) {
|
||||
setPages([]);
|
||||
setLoading(false);
|
||||
setTotalPages(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`;
|
||||
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(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal);
|
||||
|
||||
if (entry.pages.length > 0) {
|
||||
const nextPages = entry.pages.slice();
|
||||
setPages(nextPages);
|
||||
setTotalPages(cachedTotal);
|
||||
} else {
|
||||
setTotalPages(0);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
const render = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
inFlightRef.current += 1;
|
||||
const current = inFlightRef.current;
|
||||
const startAt = (entry?.pages?.length ?? 0) + 1;
|
||||
const previews = await renderPdfDocumentToImages(
|
||||
file,
|
||||
(batch) => {
|
||||
if (cancelled || current !== inFlightRef.current) return;
|
||||
appendBatchToCache(key, batch, lastKnownTotal || cachedTotal);
|
||||
},
|
||||
16,
|
||||
(total) => {
|
||||
if (!cancelled && current === inFlightRef.current) {
|
||||
lastKnownTotal = total;
|
||||
setTotalInCache(key, total);
|
||||
}
|
||||
},
|
||||
startAt,
|
||||
() => cancelled || current !== inFlightRef.current
|
||||
);
|
||||
if (!cancelled && current === inFlightRef.current) {
|
||||
const stillLatest = latestVersionMap.get(key) === refreshVersion;
|
||||
if (!stillLatest) {
|
||||
return;
|
||||
}
|
||||
const cacheEntry = getOrCreateEntry(key);
|
||||
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length;
|
||||
lastKnownTotal = 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);
|
||||
if (!cancelled) {
|
||||
setPages([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [file, enabled, cacheKey]);
|
||||
|
||||
return { pages, loading, totalPages, renderedPages: pages.length };
|
||||
};
|
||||
|
||||
export type UseComparePagePreviewsReturn = ReturnType<typeof useComparePagePreviews>;
|
||||
@@ -0,0 +1,895 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
import type {
|
||||
PagePreview,
|
||||
ComparePane as Pane,
|
||||
PanState,
|
||||
ScrollLinkDelta,
|
||||
ScrollLinkAnchors,
|
||||
PanDragState,
|
||||
PinchState,
|
||||
UseComparePanZoomOptions,
|
||||
UseComparePanZoomReturn,
|
||||
} from '@app/types/compare';
|
||||
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 100000;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
// Default structural adjustments applied to each rendered page row. These are
|
||||
// refined at runtime via DOM measurements once the panes have mounted.
|
||||
const DEFAULT_ROW_STRUCTURAL_EXTRA = 32;
|
||||
const DEFAULT_ROW_GAP = 8;
|
||||
|
||||
// (Interfaces moved to @app/types/compare)
|
||||
|
||||
export const useComparePanZoom = ({
|
||||
basePages,
|
||||
comparisonPages,
|
||||
prefersStacked,
|
||||
}: UseComparePanZoomOptions): UseComparePanZoomReturn => {
|
||||
const baseScrollRef = useRef<HTMLDivElement>(null);
|
||||
const comparisonScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isSyncingRef = useRef(false);
|
||||
const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false });
|
||||
const scrollLinkDeltaRef = useRef<ScrollLinkDelta>({ vertical: 0, horizontal: 0 });
|
||||
const scrollLinkAnchorsRef = useRef<ScrollLinkAnchors>({
|
||||
deltaPixelsBaseToComp: 0,
|
||||
deltaPixelsCompToBase: 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 edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||
const [rowStructuralExtraPx, setRowStructuralExtraPx] = useState(DEFAULT_ROW_STRUCTURAL_EXTRA);
|
||||
const [rowGapPx, setRowGapPx] = useState(DEFAULT_ROW_GAP);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
// rAF-coalesced follower scroll writes
|
||||
const syncRafRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||
const desiredTopRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||
|
||||
const canonicalLayout = useMemo(() => {
|
||||
const baseMap = new Map<number, PagePreview>();
|
||||
const compMap = new Map<number, PagePreview>();
|
||||
for (const page of basePages) baseMap.set(page.pageNumber, page);
|
||||
for (const page of comparisonPages) compMap.set(page.pageNumber, page);
|
||||
|
||||
const allPageNumbers = Array.from(
|
||||
new Set([
|
||||
...basePages.map(p => p.pageNumber),
|
||||
...comparisonPages.map(p => p.pageNumber),
|
||||
])
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
const rows = allPageNumbers.map(pageNumber => {
|
||||
const basePage = baseMap.get(pageNumber) ?? null;
|
||||
const compPage = compMap.get(pageNumber) ?? null;
|
||||
const canonicalHeight = Math.max(basePage?.height ?? 0, compPage?.height ?? 0);
|
||||
return {
|
||||
pageNumber,
|
||||
canonicalHeight,
|
||||
hasBase: Boolean(basePage),
|
||||
hasComparison: Boolean(compPage),
|
||||
};
|
||||
});
|
||||
|
||||
const totalCanonicalHeight = rows.reduce((sum, row) => sum + Math.round(row.canonicalHeight), 0);
|
||||
return { rows, totalCanonicalHeight };
|
||||
}, [basePages, comparisonPages]);
|
||||
|
||||
// Measure structural padding (labels, internal gaps) and the inter-row gap
|
||||
// so the scroll mapper can account for real DOM layout instead of relying on
|
||||
// bare page image heights.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (canonicalLayout.rows.length === 0) return;
|
||||
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const sourceContent =
|
||||
baseScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content') ??
|
||||
comparisonScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content');
|
||||
if (!sourceContent) return;
|
||||
|
||||
const style = window.getComputedStyle(sourceContent);
|
||||
const gapStr = style.rowGap || style.gap;
|
||||
const parsedGap = gapStr ? Number.parseFloat(gapStr) : Number.NaN;
|
||||
const measuredGap = Number.isNaN(parsedGap) ? rowGapPx : Math.max(0, Math.round(parsedGap));
|
||||
if (measuredGap !== rowGapPx) {
|
||||
setRowGapPx(measuredGap);
|
||||
}
|
||||
|
||||
const totalGap = Math.max(0, canonicalLayout.rows.length - 1) * measuredGap;
|
||||
const contentHeight = Math.round(sourceContent.scrollHeight);
|
||||
const available = contentHeight - totalGap - canonicalLayout.totalCanonicalHeight;
|
||||
const candidate = canonicalLayout.rows.length > 0
|
||||
? Math.max(0, Math.round(available / canonicalLayout.rows.length))
|
||||
: 0;
|
||||
|
||||
if (Math.abs(candidate - rowStructuralExtraPx) >= 1) {
|
||||
setRowStructuralExtraPx(candidate);
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [canonicalLayout, rowGapPx, rowStructuralExtraPx, layout]);
|
||||
|
||||
// Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height
|
||||
const rowHeights = useMemo(() => {
|
||||
const totalRows = canonicalLayout.rows.length;
|
||||
const base: number[] = [];
|
||||
const comp: number[] = [];
|
||||
for (let index = 0; index < totalRows; index += 1) {
|
||||
const row = canonicalLayout.rows[index];
|
||||
const canonicalHeight = Math.round(row.canonicalHeight);
|
||||
const structuralHeight = Math.max(0, Math.round(canonicalHeight + rowStructuralExtraPx));
|
||||
const includeGap = index < totalRows - 1 ? rowGapPx : 0;
|
||||
const totalHeight = structuralHeight + includeGap;
|
||||
if (row.hasBase) base.push(totalHeight);
|
||||
else if (row.hasComparison) base.push(totalHeight);
|
||||
if (row.hasComparison) comp.push(totalHeight);
|
||||
else if (row.hasBase) comp.push(totalHeight);
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]);
|
||||
|
||||
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 setPanToTopLeft = useCallback((pane: Pane) => {
|
||||
if (pane === 'base') {
|
||||
setBasePan({ x: 0, y: 0 });
|
||||
} else {
|
||||
setComparisonPan({ x: 0, y: 0 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const sourceIsBase = source === baseScrollRef.current;
|
||||
const sourceKey = sourceIsBase ? 'base' : 'comparison';
|
||||
|
||||
// Only sync if this scroll was initiated by the user (wheel/scrollbar/keyboard),
|
||||
// not by our own programmatic scrolls.
|
||||
if (!userScrollRef.current[sourceKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActivePaneRef.current = sourceIsBase ? 'base' : 'comparison';
|
||||
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
|
||||
// Use pixel anchors captured at link time to preserve offset
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
|
||||
const rawDesired = mappedTop + deltaPx;
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, rawDesired));
|
||||
|
||||
// If the mapping requests a position beyond target bounds and the target is already
|
||||
// at that bound, skip writing to avoid any subtle feedback that could impede
|
||||
// continued scrolling in the source pane.
|
||||
const atTopBound = desiredTop === 0 && target.scrollTop === 0 && rawDesired < 0;
|
||||
const atBottomBound = desiredTop === targetVerticalRange && target.scrollTop === targetVerticalRange && rawDesired > targetVerticalRange;
|
||||
if (atTopBound || atBottomBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIsBase = target === baseScrollRef.current;
|
||||
const key = targetIsBase ? 'base' : 'comparison';
|
||||
|
||||
desiredTopRef.current[key] = desiredTop;
|
||||
if (syncRafRef.current[key] == null) {
|
||||
syncRafRef.current[key] = requestAnimationFrame(() => {
|
||||
const el = targetIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const top = desiredTopRef.current[key] ?? 0;
|
||||
if (el) {
|
||||
isSyncingRef.current = true;
|
||||
el.scrollTop = top;
|
||||
}
|
||||
syncRafRef.current[key] = null;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||
);
|
||||
|
||||
// Track user-initiated scroll state per pane
|
||||
useEffect(() => {
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) return;
|
||||
|
||||
const onUserScrollStartBase = () => { userScrollRef.current.base = true; };
|
||||
const onUserScrollStartComp = () => { userScrollRef.current.comparison = true; };
|
||||
const onUserScrollEndBase = () => { userScrollRef.current.base = false; };
|
||||
const onUserScrollEndComp = () => { userScrollRef.current.comparison = false; };
|
||||
|
||||
const addUserListeners = (el: HTMLDivElement, onStart: () => void, onEnd: () => void) => {
|
||||
el.addEventListener('wheel', onStart, { passive: true });
|
||||
el.addEventListener('mousedown', onStart, { passive: true });
|
||||
el.addEventListener('touchstart', onStart, { passive: true });
|
||||
// Heuristic: clear the flag shortly after scroll events settle
|
||||
let timeout: number | null = null;
|
||||
const onScroll = () => {
|
||||
// Ignore programmatic scrolls to avoid feedback loops and unnecessary syncing work
|
||||
if (isSyncingRef.current) return;
|
||||
onStart();
|
||||
if (timeout != null) window.clearTimeout(timeout);
|
||||
timeout = window.setTimeout(onEnd, 120);
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onStart as any);
|
||||
el.removeEventListener('mousedown', onStart as any);
|
||||
el.removeEventListener('touchstart', onStart as any);
|
||||
el.removeEventListener('scroll', onScroll as any);
|
||||
if (timeout != null) window.clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupBase = addUserListeners(baseEl, onUserScrollStartBase, onUserScrollEndBase);
|
||||
const cleanupComp = addUserListeners(compEl, onUserScrollStartComp, onUserScrollEndComp);
|
||||
return () => { cleanupBase(); cleanupComp(); };
|
||||
}, []);
|
||||
|
||||
// Helpers for clearer pan-edge overscroll behavior
|
||||
const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => {
|
||||
if (rawY < 0) return rawY; // negative -> scroll up
|
||||
if (rawY > maxY) return rawY - maxY; // positive -> scroll down
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
const normalizeApplyCandidate = useCallback((overflowY: number): number => {
|
||||
const DEADZONE = 32; // pixels
|
||||
if (overflowY < -DEADZONE) return overflowY + DEADZONE;
|
||||
if (overflowY > DEADZONE) return overflowY - DEADZONE;
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => {
|
||||
const STEP = 48; // pixels per incremental scroll
|
||||
const key = isBase ? 'base' : 'comparison';
|
||||
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
|
||||
const magnitude = Math.abs(deltaSinceLast);
|
||||
if (magnitude < STEP) return;
|
||||
|
||||
const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
|
||||
edgeOverscrollRef.current[key] += stepDelta;
|
||||
|
||||
const prevTop = container.scrollTop;
|
||||
const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
|
||||
if (nextTop === prevTop) return;
|
||||
|
||||
container.scrollTop = nextTop;
|
||||
if (isScrollLinked) {
|
||||
const sourceIsBase = isBase;
|
||||
const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (target) {
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||
target.scrollTop = desiredTop;
|
||||
}
|
||||
}
|
||||
}, [isScrollLinked, mapScrollTopBetweenPanes]);
|
||||
|
||||
const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => {
|
||||
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (!container) return;
|
||||
const overflowY = getVerticalOverflow(rawY, boundsMaxY);
|
||||
const applyCandidate = normalizeApplyCandidate(overflowY);
|
||||
if (applyCandidate !== 0) {
|
||||
applyIncrementalScroll(container, isBase, applyCandidate);
|
||||
} else {
|
||||
// Reset accumulator when back within deadzone
|
||||
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
|
||||
}
|
||||
}, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]);
|
||||
|
||||
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,
|
||||
};
|
||||
edgeOverscrollRef.current[pane] = 0;
|
||||
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 rawX = drag.startPanX - dx;
|
||||
const rawY = drag.startPanY - dy;
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||
};
|
||||
|
||||
// On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
|
||||
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
// When the source pane hits its scroll limit but the peer still has room,
|
||||
// propagate the wheel delta to the peer so it continues following.
|
||||
const handleWheelOverscroll = useCallback(
|
||||
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (event.ctrlKey) return; // handled by zoom handler
|
||||
if (!isScrollLinked) return;
|
||||
|
||||
const source = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const target = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (!source || !target) return;
|
||||
|
||||
const deltaY = event.deltaY;
|
||||
if (deltaY === 0) return;
|
||||
|
||||
const sourceMax = Math.max(0, source.scrollHeight - source.clientHeight);
|
||||
const nextSource = Math.max(0, Math.min(sourceMax, source.scrollTop + deltaY));
|
||||
|
||||
// If the source can scroll, let the normal scroll event drive syncing
|
||||
if (nextSource !== source.scrollTop) return;
|
||||
|
||||
// Source is at a bound; push mapped delta into the target
|
||||
const sourceIsBase = pane === 'base';
|
||||
|
||||
// Map the desired new source position (scrollTop + deltaY) into target space
|
||||
const mappedBefore = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
const mappedAfter = mapScrollTopBetweenPanes(source.scrollTop + deltaY, sourceIsBase);
|
||||
const mappedDelta = mappedAfter - mappedBefore;
|
||||
|
||||
// Include the pixel anchor captured when linking
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
|
||||
const targetMax = Math.max(0, target.scrollHeight - target.clientHeight);
|
||||
const desired = Math.max(0, Math.min(targetMax, target.scrollTop + (mappedDelta || deltaY)));
|
||||
|
||||
if (desired !== target.scrollTop) {
|
||||
isSyncingRef.current = true;
|
||||
// Adjust relative to mapped space to keep the anchor consistent
|
||||
const anchored = Math.max(0, Math.min(targetMax, mappedBefore + deltaPx + (mappedDelta || deltaY)));
|
||||
target.scrollTop = anchored;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
edgeOverscrollRef.current[pane] = 0;
|
||||
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 rawX = panDragRef.current.startPanX - dx;
|
||||
const rawY = panDragRef.current.startPanY - dy;
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||
};
|
||||
|
||||
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||
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;
|
||||
}, []);
|
||||
|
||||
// Auto-toggle Pan Mode based on zoom level
|
||||
useEffect(() => {
|
||||
const shouldPan = baseZoom > 1 || comparisonZoom > 1;
|
||||
if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
|
||||
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||
|
||||
// When new pages render and extend scrollHeight, re-apply the mapping so
|
||||
// the follower continues tracking instead of getting stuck at its prior max.
|
||||
useEffect(() => {
|
||||
if (!isScrollLinked) return;
|
||||
const sourceIsBase = lastActivePaneRef.current === 'base';
|
||||
const source = sourceIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const target = sourceIsBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (!source || !target) return;
|
||||
|
||||
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||
|
||||
if (Math.abs(target.scrollTop - desiredTop) > 1) {
|
||||
isSyncingRef.current = true;
|
||||
target.scrollTop = desiredTop;
|
||||
requestAnimationFrame(() => { isSyncingRef.current = false; });
|
||||
}
|
||||
}, [basePages.length, comparisonPages.length, isScrollLinked, mapScrollTopBetweenPanes]);
|
||||
|
||||
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 };
|
||||
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 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,
|
||||
};
|
||||
|
||||
// Capture pixel anchors in mapped space
|
||||
const mappedBaseToComp = mapScrollTopBetweenPanes(baseEl.scrollTop, true);
|
||||
const mappedCompToBase = mapScrollTopBetweenPanes(compEl.scrollTop, false);
|
||||
scrollLinkAnchorsRef.current = {
|
||||
deltaPixelsBaseToComp: compEl.scrollTop - mappedBaseToComp,
|
||||
deltaPixelsCompToBase: baseEl.scrollTop - mappedCompToBase,
|
||||
};
|
||||
}, [mapScrollTopBetweenPanes]);
|
||||
|
||||
const clearScrollLinkDelta = useCallback(() => {
|
||||
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 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,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
handleScrollSync,
|
||||
beginPan,
|
||||
continuePan,
|
||||
endPan,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
zoomLimits,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useMemo } from 'react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
import type { RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
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;
|
||||
setPanToTopLeft: (pane: Pane) => 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 };
|
||||
baseScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const useCompareRightRailButtons = ({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
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-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,
|
||||
disabled: baseZoom === 1 && comparisonZoom === 1,
|
||||
onClick: () => {
|
||||
setBaseZoom(1);
|
||||
setComparisonZoom(1);
|
||||
setPanToTopLeft('base');
|
||||
setPanToTopLeft('comparison');
|
||||
clearScrollLinkDelta();
|
||||
// Reset scrollTop for both panes to realign view
|
||||
if (baseScrollRef?.current) {
|
||||
baseScrollRef.current.scrollTop = 0;
|
||||
}
|
||||
if (comparisonScrollRef?.current) {
|
||||
comparisonScrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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.unlinkScroll', 'Unlink scroll')
|
||||
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||
ariaLabel: isScrollLinked
|
||||
? t('compare.actions.unlinkScroll', 'Unlink scroll')
|
||||
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||
section: 'top',
|
||||
order: 15,
|
||||
onClick: () => {
|
||||
const next = !isScrollLinked;
|
||||
if (next) {
|
||||
captureScrollLinkDelta();
|
||||
} else {
|
||||
if (!isMobile) {
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsScrollLinked(next);
|
||||
},
|
||||
},
|
||||
], [
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
setPanToTopLeft,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
t,
|
||||
isMobile,
|
||||
]);
|
||||
};
|
||||
|
||||
export type UseCompareRightRailButtonsReturn = ReturnType<typeof useCompareRightRailButtons>;
|
||||
@@ -87,7 +87,7 @@ const FileStatusIndicator = ({
|
||||
<Text size="sm" c="dimmed">
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
onClick={() => openFilesModal({})}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||
@@ -122,7 +122,7 @@ const FileStatusIndicator = ({
|
||||
{getPlaceholder() + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
onClick={() => openFilesModal({})}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||
|
||||
@@ -86,7 +86,6 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
{config.steps.map((stepConfig) =>
|
||||
steps.create(stepConfig.title, {
|
||||
isVisible: stepConfig.isVisible,
|
||||
isCollapsed: stepConfig.isCollapsed,
|
||||
onCollapsedClick: stepConfig.onCollapsedClick,
|
||||
tooltip: stepConfig.tooltip
|
||||
}, stepConfig.content)
|
||||
|
||||
@@ -13,14 +13,14 @@ export const useOverlayPdfsTips = (): TooltipContent => {
|
||||
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
|
||||
description: t(
|
||||
'overlay-pdfs.tooltip.description.text',
|
||||
'Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
|
||||
'Combine a original PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'),
|
||||
description: t(
|
||||
'overlay-pdfs.tooltip.mode.text',
|
||||
'Choose how to distribute overlay pages across the base PDF pages.'
|
||||
'Choose how to distribute overlay pages across the original PDF pages.'
|
||||
),
|
||||
bullets: [
|
||||
t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'),
|
||||
@@ -39,7 +39,7 @@ export const useOverlayPdfsTips = (): TooltipContent => {
|
||||
title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'),
|
||||
description: t(
|
||||
'overlay-pdfs.tooltip.overlayFiles.text',
|
||||
'Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
|
||||
'Select one or more PDFs to overlay on the original. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@@ -62,8 +62,8 @@ export function useViewerRightRailButtons() {
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanning ? 'filled' : 'subtle'}
|
||||
color={isPanning ? 'blue' : undefined}
|
||||
variant={isPanning ? 'default' : 'subtle'}
|
||||
color={undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
@@ -71,6 +71,7 @@ export function useViewerRightRailButtons() {
|
||||
setIsPanning(prev => !prev);
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
|
||||
Reference in New Issue
Block a user