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 <jbrunton96@gmail.com>
This commit is contained in:
EthanHealy01 2025-11-12 14:54:01 +00:00 committed by GitHub
parent f22f697edc
commit a5e2b54274
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 6651 additions and 81 deletions

View File

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

View File

@ -38,6 +38,7 @@
"language": {
"direction": "ltr"
},
"cancel": "Cancel",
"addPageNumbers": {
"fontSize": "Font Size",
"fontName": "Font Name",
@ -2157,15 +2158,99 @@
"tags": "differentiate,contrast,changes,analysis",
"title": "Compare",
"header": "Compare PDFs",
"highlightColor": {
"1": "Highlight Colour 1:",
"2": "Highlight Colour 2:"
"clearSelected": "Clear selected",
"clear": {
"confirmTitle": "Clear selected PDFs?",
"confirmBody": "This will close the current comparison and take you back to Active Files.",
"confirm": "Clear and return"
},
"document": {
"1": "Document 1",
"2": "Document 2"
"review": {
"title": "Comparison Result",
"actionsHint": "Review the comparison, switch document roles, or export the summary.",
"switchOrder": "Switch order",
"exportSummary": "Export summary"
},
"submit": "Compare",
"base": {
"label": "Original document",
"placeholder": "Select the original PDF"
},
"comparison": {
"label": "Edited document",
"placeholder": "Select the edited PDF"
},
"addFilesHint": "Add PDFs in the Files step to enable selection.",
"noFiles": "No PDFs available yet",
"pages": "Pages",
"selection": {
"originalEditedTitle": "Select Original and Edited PDFs"
},
"original": { "label": "Original PDF" },
"edited": { "label": "Edited PDF" },
"swap": {
"confirmTitle": "Re-run comparison?",
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
"confirm": "Swap and Re-run"
},
"cta": "Compare",
"loading": "Comparing...",
"summary": {
"baseHeading": "Original document",
"comparisonHeading": "Edited document",
"pageLabel": "Page"
},
"rendering": {
"pageNotReadyTitle": "Page not rendered yet",
"pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.",
"rendering": "rendering",
"inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete",
"pagesRendered": "pages rendered",
"complete": "Page rendering complete"
},
"dropdown": {
"deletionsLabel": "Deletions",
"additionsLabel": "Additions",
"deletions": "Deletions ({{count}})",
"additions": "Additions ({{count}})",
"searchPlaceholder": "Search changes...",
"noResults": "No changes found"
},
"actions": {
"stackVertically": "Stack vertically",
"placeSideBySide": "Place side by side",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"resetView": "Reset view",
"unlinkScrollPan": "Unlink scroll and pan",
"linkScrollPan": "Link scroll and pan",
"unlinkScroll": "Unlink scroll",
"linkScroll": "Link scroll"
},
"toasts": {
"unlinkedTitle": "Independent scroll & pan enabled",
"unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
},
"error": {
"selectRequired": "Select a original and edited document.",
"filesMissing": "Unable to locate the selected files. Please re-select them.",
"generic": "Unable to compare these files."
},
"status": {
"extracting": "Extracting text...",
"processing": "Analysing differences...",
"complete": "Comparison ready"
},
"longJob": {
"title": "Large comparison in progress",
"body": "These PDFs together exceed 2,000 pages. Processing can take several minutes."
},
"slowOperation": {
"title": "Still working…",
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
"cancel": "Cancel comparison"
},
"newLine": "new-line",
"complex": {
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
},
@ -2178,6 +2263,16 @@
"text": {
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
}
},
"too": {
"dissimilar": {
"message": "These documents appear highly dissimilar. Comparison was stopped to save time."
}
},
"earlyDissimilarity": {
"title": "These PDFs look highly different",
"body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.",
"stopButton": "Stop comparison"
}
},
"certSign": {

View File

@ -2443,15 +2443,99 @@
"tags": "differentiate,contrast,changes,analysis",
"title": "Compare",
"header": "Compare PDFs",
"highlightColor": {
"1": "Highlight Color 1:",
"2": "Highlight Color 2:"
"clearSelected": "Clear selected",
"clear": {
"confirmTitle": "Clear selected PDFs?",
"confirmBody": "This will close the current comparison and take you back to Active Files.",
"confirm": "Clear and return"
},
"document": {
"1": "Document 1",
"2": "Document 2"
"review": {
"title": "Comparison Result",
"actionsHint": "Review the comparison, switch document roles, or export the summary.",
"switchOrder": "Switch order",
"exportSummary": "Export summary"
},
"submit": "Compare",
"base": {
"label": "Original document",
"placeholder": "Select the original PDF"
},
"comparison": {
"label": "Edited document",
"placeholder": "Select the edited PDF"
},
"addFilesHint": "Add PDFs in the Files step to enable selection.",
"noFiles": "No PDFs available yet",
"pages": "Pages",
"selection": {
"originalEditedTitle": "Select Original and Edited PDFs"
},
"original": { "label": "Original PDF" },
"edited": { "label": "Edited PDF" },
"swap": {
"confirmTitle": "Re-run comparison?",
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
"confirm": "Swap and Re-run"
},
"cta": "Compare",
"loading": "Comparing...",
"summary": {
"baseHeading": "Original document",
"comparisonHeading": "Edited document",
"pageLabel": "Page"
},
"rendering": {
"pageNotReadyTitle": "Page not rendered yet",
"pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.",
"rendering": "rendering",
"inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete",
"pagesRendered": "pages rendered",
"complete": "Page rendering complete"
},
"dropdown": {
"deletionsLabel": "Deletions",
"additionsLabel": "Additions",
"deletions": "Deletions ({{count}})",
"additions": "Additions ({{count}})",
"searchPlaceholder": "Search changes...",
"noResults": "No changes found"
},
"actions": {
"stackVertically": "Stack vertically",
"placeSideBySide": "Place side by side",
"zoomOut": "Zoom out",
"zoomIn": "Zoom in",
"resetView": "Reset view",
"unlinkScrollPan": "Unlink scroll and pan",
"linkScrollPan": "Link scroll and pan",
"unlinkScroll": "Unlink scroll",
"linkScroll": "Link scroll"
},
"toasts": {
"unlinkedTitle": "Independent scroll & pan enabled",
"unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
},
"error": {
"selectRequired": "Select a original and edited document.",
"filesMissing": "Unable to locate the selected files. Please re-select them.",
"generic": "Unable to compare these files."
},
"status": {
"extracting": "Extracting text...",
"processing": "Analysing differences...",
"complete": "Comparison ready"
},
"longJob": {
"title": "Large comparison in progress",
"body": "These PDFs together exceed 2,000 pages. Processing can take several minutes."
},
"slowOperation": {
"title": "Still working…",
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
"cancel": "Cancel comparison"
},
"newLine": "new-line",
"complex": {
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
},
@ -2464,6 +2548,16 @@
"text": {
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
}
},
"too": {
"dissimilar": {
"message": "These documents appear highly dissimilar. Comparison was stopped to save time."
}
},
"earlyDissimilarity": {
"title": "These PDFs look highly different",
"body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.",
"stopButton": "Stop comparison"
}
},
"certSign": {
@ -3115,7 +3209,7 @@
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
"label": "Select Original PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files",

View File

@ -2986,7 +2986,7 @@
"title": "Overlay PDFs",
"desc": "Overlay one PDF on top of another",
"baseFile": {
"label": "Select Base PDF File"
"label": "Select Original PDF File"
},
"overlayFiles": {
"label": "Select Overlay PDF Files",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' }} />

View File

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

View File

@ -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.'
)
},
{

View File

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

View File

@ -1,7 +1,9 @@
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFileActions } from '@app/contexts/FileContext';
import { useFileContext } from '@app/contexts/file/fileHooks';
import { StirlingFileStub } from '@app/types/fileContext';
import type { FileId } from '@app/types/file';
import { fileStorage } from '@app/services/fileStorage';
interface FilesModalContextType {
@ -19,6 +21,7 @@ const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { addFiles } = useFileHandler();
const { actions } = useFileActions();
const fileCtx = useFileContext();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
@ -37,16 +40,25 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
onModalClose?.();
}, [onModalClose]);
const handleFileUpload = useCallback((files: File[]) => {
const handleFileUpload = useCallback(async (files: File[]) => {
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler(files, insertAfterPage);
} else {
// Use normal file handling
addFiles(files);
// 1) Add via standard flow (auto-selects new files)
await addFiles(files);
// 2) Merge all requested file IDs (covers already-present files too)
const ids = files
.map((f) => fileCtx.findFileId(f) as FileId | undefined)
.filter((id): id is FileId => Boolean(id));
if (ids.length > 0) {
const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id);
const nextSelection = Array.from(new Set([...currentSelected, ...ids]));
actions.setSelectedFiles(nextSelection);
}
}
closeFilesModal();
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
}, [addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx]);
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
if (customHandler) {
@ -67,15 +79,22 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
console.error('Failed to load files for custom handler:', error);
}
} else {
// Normal case - use addStirlingFileStubs to preserve metadata
// Normal case - use addStirlingFileStubs to preserve metadata (auto-selects new)
if (actions.addStirlingFileStubs) {
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
await actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
// Merge all requested IDs into selection (covers files that already existed)
const requestedIds = stirlingFileStubs.map((s) => s.id);
if (requestedIds.length > 0) {
const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id);
const nextSelection = Array.from(new Set([...currentSelected, ...requestedIds]));
actions.setSelectedFiles(nextSelection);
}
} else {
console.error('addStirlingFileStubs action not available');
}
}
closeFilesModal();
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage, actions, fileCtx]);
const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback);

View File

@ -421,4 +421,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
return context;
}
}

View File

@ -122,8 +122,11 @@ import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbe
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
import ValidateSignature from "@app/tools/ValidateSignature";
import Automate from "@app/tools/Automate";
import Compare from "@app/tools/Compare";
import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats";
export interface TranslatedToolCatalog {
allTools: ToolRegistry;
regularTools: RegularToolRegistry;
@ -772,13 +775,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
compare: {
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.compare.title", "Compare"),
component: null,
component: Compare,
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: 2,
operationConfig: undefined,
automationSettings: null,
synonyms: getSynonyms(t, "compare"),
supportsAutomate: false,
automationSettings: null
supportsAutomate: false
},
compress: {
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,

View File

@ -0,0 +1,566 @@
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { appendWord as sharedAppendWord } from '@app/utils/textDiff';
import { PARAGRAPH_SENTINEL } from '@app/types/compare';
import type { StirlingFile } from '@app/types/fileContext';
import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
import type {
CompareChange,
CompareDiffToken,
CompareResultData,
TokenBoundingBox,
CompareParagraph,
} from '@app/types/compare';
export interface TokenMetadata {
page: number;
paragraph: number;
bbox: TokenBoundingBox | null;
}
export interface ExtractedContent {
tokens: string[];
metadata: TokenMetadata[];
pageSizes: { width: number; height: number }[];
paragraphs: CompareParagraph[];
}
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
let lastMeasurementFont = '';
const DEFAULT_CHAR_WIDTH = 1;
const DEFAULT_SPACE_WIDTH = 0.33;
export const measureTextWidth = (fontSpec: string, text: string): number => {
if (!measurementContext) {
if (!text) return 0;
if (text === ' ') return DEFAULT_SPACE_WIDTH;
return text.length * DEFAULT_CHAR_WIDTH;
}
if (lastMeasurementFont !== fontSpec) {
measurementContext.font = fontSpec;
lastMeasurementFont = fontSpec;
}
const key = `${fontSpec}|${text}`;
const cached = textMeasurementCache?.get(key);
if (cached !== undefined) {
return cached;
}
const width = measurementContext.measureText(text).width || 0;
textMeasurementCache?.set(key, width);
return width;
};
export const appendWord = (existing: string, word: string) => {
if (!existing) {
return sharedAppendWord('', word);
}
return sharedAppendWord(existing, word);
};
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
return tokens.reduce(
(totals, token) => {
if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized
return totals;
}
switch (token.type) {
case 'added':
totals.added += 1;
break;
case 'removed':
totals.removed += 1;
break;
default:
totals.unchanged += 1;
}
return totals;
},
{ added: 0, removed: 0, unchanged: 0 }
);
};
export const buildChanges = (
tokens: CompareDiffToken[],
baseMetadata: TokenMetadata[],
comparisonMetadata: TokenMetadata[]
): CompareChange[] => {
const changes: CompareChange[] = [];
let baseIndex = 0;
let comparisonIndex = 0;
let current: CompareChange | null = null;
let currentBaseParagraph: number | null = null;
let currentComparisonParagraph: number | null = null;
const ensureCurrent = (): CompareChange => {
if (!current) {
current = {
id: `change-${changes.length}`,
base: null,
comparison: null,
};
}
return current;
};
const flush = () => {
if (current) {
if (current.base) {
current.base.text = current.base.text.trim();
}
if (current.comparison) {
current.comparison.text = current.comparison.text.trim();
}
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
changes.push(current);
}
}
current = null;
currentBaseParagraph = null;
currentComparisonParagraph = null;
};
for (const token of tokens) {
if (token.type === 'removed') {
const meta = baseMetadata[baseIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.base) {
active.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentBaseParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentBaseParagraph !== null &&
paragraph !== currentBaseParagraph &&
active.base.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.base = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.base.text = appendWord(active.base.text, token.text);
}
if (meta && active.base.page === null) {
active.base.page = meta.page;
}
if (meta && active.base.paragraph === null) {
active.base.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentBaseParagraph = paragraph;
}
}
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
continue;
}
if (token.type === 'added') {
const meta = comparisonMetadata[comparisonIndex] ?? null;
const active = ensureCurrent();
const paragraph = meta?.paragraph ?? null;
if (!active.comparison) {
active.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
};
currentComparisonParagraph = paragraph;
} else {
if (
paragraph !== null &&
currentComparisonParagraph !== null &&
paragraph !== currentComparisonParagraph &&
active.comparison.text.trim().length > 0
) {
flush();
const next = ensureCurrent();
next.comparison = {
text: token.text,
page: meta?.page ?? null,
paragraph: paragraph,
};
} else {
active.comparison.text = appendWord(active.comparison.text, token.text);
}
if (meta && active.comparison.page === null) {
active.comparison.page = meta.page;
}
if (meta && active.comparison.paragraph === null) {
active.comparison.paragraph = meta.paragraph;
}
if (paragraph !== null) {
currentComparisonParagraph = paragraph;
}
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
continue;
}
// unchanged token
flush();
if (baseIndex < baseMetadata.length) {
baseIndex += 1;
}
if (comparisonIndex < comparisonMetadata.length) {
comparisonIndex += 1;
}
}
flush();
return changes;
};
export const createSummaryFile = (result: CompareResultData): File => {
const exportPayload = {
generatedAt: new Date(result.totals.processedAt).toISOString(),
base: {
name: result.base.fileName,
totalWords: result.base.wordCount,
},
comparison: {
name: result.comparison.fileName,
totalWords: result.comparison.wordCount,
},
totals: {
added: result.totals.added,
removed: result.totals.removed,
unchanged: result.totals.unchanged,
durationMs: result.totals.durationMs,
},
changes: result.changes.map((change) => ({
base: change.base,
comparison: change.comparison,
})),
warnings: result.warnings,
};
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
};
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' | undefined => {
if (typeof value === 'object' && value !== null && 'code' in value) {
const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' }).code;
return potentialCode;
}
return undefined;
};
// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers,
// returning a mapping to original indices for potential future use.
export const filterTokensForDiff = (
tokens: string[],
metadata: TokenMetadata[],
): { tokens: string[]; metadata: TokenMetadata[]; filteredToOriginal: number[] } => {
const outTokens: string[] = [];
const outMeta: TokenMetadata[] = [];
const map: number[] = [];
for (let i = 0; i < tokens.length; i += 1) {
const t = tokens[i];
const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA');
if (!isPara) {
outTokens.push(t);
if (metadata[i]) outMeta.push(metadata[i]);
map.push(i);
}
}
return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map };
};
export const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
try {
const tokens: string[] = [];
const metadata: TokenMetadata[] = [];
const pageSizes: { width: number; height: number }[] = [];
const paragraphs: CompareParagraph[] = [];
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
const viewport = page.getViewport({ scale: 1 });
const content: TextContent = await page.getTextContent({
disableCombineTextItems: true,
} as Parameters<PDFPageProxy['getTextContent']>[0]);
const styles: Record<string, { fontFamily?: string; ascent?: number; descent?: number }> = content.styles ?? {};
let paragraphIndex = 1;
let paragraphBuffer = '';
let prevItem: TextItem | null = null;
pageSizes.push({ width: viewport.width, height: viewport.height });
const normalizeToken = (s: string) =>
s
.normalize('NFKC')
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
.replace(/[“”]/g, '"')
.replace(/[]/g, "'")
.replace(/[–—]/g, '-')
.replace(/\u00A0/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => {
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
if (hasHardBreak) return true;
if (!prev) return false;
const prevY = prev.transform[5];
const currY = curr.transform[5];
const dy = Math.abs(currY - prevY);
const currX = curr.transform[4];
const prevX = prev.transform[4];
const approxLine = Math.max(10, Math.abs((curr as any).height ?? 0) * 0.9);
const looksLikeParagraph = dy > approxLine * 1.8;
const likelySoftWrap = currX < prevX && dy < approxLine * 0.6;
return looksLikeParagraph && !likelySoftWrap;
};
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
if (width <= 0 || height <= 0) {
return null;
}
const MIN_WIDTH = 0.004;
const MIN_HORIZONTAL_PAD = 0.0012;
const HORIZONTAL_PAD_RATIO = 0.12;
const MIN_VERTICAL_PAD = 0.0008;
const VERTICAL_PAD_RATIO = 0.18;
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
let expandedLeft = left - horizontalPad;
let expandedRight = left + width + horizontalPad;
let expandedTop = top - verticalPad;
let expandedBottom = top + height + verticalPad;
if (expandedRight - expandedLeft < MIN_WIDTH) {
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
expandedLeft -= deficit / 2;
expandedRight += deficit / 2;
}
expandedLeft = clamp(expandedLeft);
expandedRight = clamp(expandedRight);
expandedTop = clamp(expandedTop);
expandedBottom = clamp(expandedBottom);
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
return null;
}
return {
left: expandedLeft,
top: expandedTop,
width: expandedRight - expandedLeft,
height: expandedBottom - expandedTop,
};
};
for (const item of content.items as TextItem[]) {
if (!item?.str) {
prevItem = null;
continue;
}
const rawText = item.str;
const totalLen = Math.max(rawText.length, 1);
const textStyle = item.fontName ? styles[item.fontName] : undefined;
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
const fontSpec = `${fontScale}px ${fontFamily}`;
const weights: number[] = new Array(totalLen);
let runningText = '';
let previousAdvance = 0;
for (let i = 0; i < totalLen; i += 1) {
runningText += rawText[i];
const advance = measureTextWidth(fontSpec, runningText);
let width = advance - previousAdvance;
if (!Number.isFinite(width) || width <= 0) {
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
weights[i] = width;
previousAdvance = advance;
}
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
for (let i = 0; i < totalLen; i += 1) {
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
}
}
const prefix: number[] = new Array(totalLen + 1);
prefix[0] = 0;
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
const totalWeight = prefix[totalLen] || 1;
const rawX = item.transform[4];
const rawY = item.transform[5];
const transformed = [
viewport.convertToViewportPoint(rawX, rawY),
viewport.convertToViewportPoint(rawX + item.width, rawY),
viewport.convertToViewportPoint(rawX, rawY + item.height),
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
];
const xs = transformed.map(([px]) => px);
const ys = transformed.map(([, py]) => py);
const left = Math.min(...xs);
const right = Math.max(...xs);
const top = Math.min(...ys);
const bottom = Math.max(...ys);
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
prevItem = item;
continue;
}
const [baselineStart, baselineEnd, verticalEnd] = transformed;
const baselineVector: [number, number] = [
baselineEnd[0] - baselineStart[0],
baselineEnd[1] - baselineStart[1],
];
const verticalVector: [number, number] = [
verticalEnd[0] - baselineStart[0],
verticalEnd[1] - baselineStart[1],
];
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
const font = item.fontName ? styles[item.fontName] : undefined;
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(rawText)) !== null) {
const wordRaw = match[0];
const normalizedWord = normalizeToken(wordRaw);
if (!normalizedWord) {
continue;
}
const startIndex = match.index;
const endIndex = startIndex + wordRaw.length;
const relStart = prefix[startIndex] / totalWeight;
const relEnd = prefix[endIndex] / totalWeight;
let wordLeftAbs: number;
let wordRightAbs: number;
let wordTopAbs: number;
let wordBottomAbs: number;
if (hasOrientationVectors) {
const segStart: [number, number] = [
baselineStart[0] + baselineVector[0] * relStart,
baselineStart[1] + baselineVector[1] * relStart,
];
const segEnd: [number, number] = [
baselineStart[0] + baselineVector[0] * relEnd,
baselineStart[1] + baselineVector[1] * relEnd,
];
const cornerPoints: Array<[number, number]> = [
segStart,
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
segEnd,
];
const cornerXs = cornerPoints.map(([px]) => px);
const cornerYs = cornerPoints.map(([, py]) => py);
wordLeftAbs = Math.min(...cornerXs);
wordRightAbs = Math.max(...cornerXs);
wordTopAbs = Math.min(...cornerYs);
wordBottomAbs = Math.max(...cornerYs);
} else {
const segLeftAbs = left + (right - left) * relStart;
const segRightAbs = left + (right - left) * relEnd;
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
wordTopAbs = top;
wordBottomAbs = bottom;
}
const wordLeft = clamp(wordLeftAbs / viewport.width);
const wordRight = clamp(wordRightAbs / viewport.width);
const wordTop = clamp(wordTopAbs / viewport.height);
const wordBottom = clamp(wordBottomAbs / viewport.height);
const wordWidth = Math.max(0, wordRight - wordLeft);
let wordHeight = Math.max(0, wordBottom - wordTop);
if (wordHeight > 0 && verticalScale < 1) {
const midY = (wordTop + wordBottom) / 2;
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
const half = shrunkHeight / 2;
const newTop = clamp(midY - half);
const newBottom = clamp(midY + half);
wordHeight = Math.max(0, newBottom - newTop);
const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight);
tokens.push(normalizedWord);
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
continue;
}
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
tokens.push(normalizedWord);
metadata.push({
page: pageIndex,
paragraph: paragraphIndex,
bbox,
});
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
}
if (isParagraphBreak(item as TextItem, prevItem)) {
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
}
tokens.push('\uE000PARA');
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
paragraphIndex += 1;
}
prevItem = item as TextItem;
}
if (paragraphBuffer.trim().length > 0) {
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
paragraphBuffer = '';
tokens.push('\uE000PARA');
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
}
}
return { tokens, metadata, pageSizes, paragraphs };
} finally {
pdfWorkerManager.destroyDocument(pdfDoc);
}
};

View File

@ -0,0 +1,559 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ADDITION_HIGHLIGHT,
CompareDiffToken,
CompareFilteredTokenInfo,
CompareResultData,
CompareWorkerRequest,
CompareWorkerResponse,
CompareWorkerWarnings,
REMOVAL_HIGHLIGHT,
} from '@app/types/compare';
import { CompareParameters } from '@app/hooks/tools/compare/useCompareParameters';
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
import type { StirlingFile } from '@app/types/fileContext';
import { useFileContext } from '@app/contexts/file/fileHooks';
import {
aggregateTotals,
buildChanges,
createSummaryFile,
extractContentFromPdf,
getWorkerErrorCode,
filterTokensForDiff,
} from '@app/hooks/tools/compare/operationUtils';
import { alert, dismissToast } from '@app/components/toast';
import type { ToastLocation } from '@app/components/toast/types';
import CompareWorkerCtor from '@app/workers/compareWorker?worker';
const LONG_RUNNING_PAGE_THRESHOLD = 2000;
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
result: CompareResultData | null;
warnings: string[];
}
// extractContentFromPdf moved to utils
export const useCompareOperation = (): CompareOperationHook => {
const { t } = useTranslation();
const { selectors } = useFileContext();
const workerRef = useRef<Worker | null>(null);
const previousUrl = useRef<string | null>(null);
const activeRunIdRef = useRef(0);
const cancelledRef = useRef(false);
type OperationStatus = 'idle' | 'extracting' | 'processing' | 'complete' | 'cancelled' | 'error';
const [isLoading, setIsLoading] = useState(false);
const [statusState, setStatusState] = useState<OperationStatus>('idle');
const [statusDetailMs, setStatusDetailMs] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadFilename, setDownloadFilename] = useState('');
const [result, setResult] = useState<CompareResultData | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const longRunningToastIdRef = useRef<string | null>(null);
const dissimilarityToastIdRef = useRef<string | null>(null);
const dissimilarityToastShownRef = useRef<boolean>(false);
const ensureWorker = useCallback(() => {
if (!workerRef.current) {
workerRef.current = new CompareWorkerCtor();
}
return workerRef.current;
}, []);
const cleanupDownloadUrl = useCallback(() => {
if (previousUrl.current) {
URL.revokeObjectURL(previousUrl.current);
previousUrl.current = null;
}
}, []);
const resetResults = useCallback(() => {
setResult(null);
setWarnings([]);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
setStatusState('idle');
setStatusDetailMs(null);
setErrorMessage(null);
}, [cleanupDownloadUrl]);
const clearError = useCallback(() => {
setErrorMessage(null);
}, []);
const runCompareWorker = useCallback(
async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings, onChunk?: (chunk: CompareDiffToken[]) => void) => {
const worker = ensureWorker();
return await new Promise<{
tokens: CompareDiffToken[];
stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number };
warnings: string[];
}>((resolve, reject) => {
const collectedWarnings: string[] = [];
const collectedTokens: CompareDiffToken[] = [];
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
if (cancelledRef.current) {
cleanup();
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
return;
}
const message = event.data;
if (!message) {
return;
}
switch (message.type) {
case 'chunk': {
if (message.tokens.length > 0) {
collectedTokens.push(...message.tokens);
onChunk?.(message.tokens);
}
break;
}
case 'success':
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
resolve({
tokens: collectedTokens,
stats: message.stats,
warnings: collectedWarnings,
});
break;
case 'warning':
collectedWarnings.push(message.message);
break;
case 'error': {
cleanup();
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' } = new Error(message.message);
error.code = message.code;
reject(error);
break;
}
default:
break;
}
};
const handleError = (event: ErrorEvent) => {
cleanup();
if (cancelledRef.current) {
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
} else {
reject(event.error ?? new Error(event.message));
}
};
const cleanup = () => {
worker.removeEventListener('message', handleMessage as EventListener);
worker.removeEventListener('error', handleError as EventListener);
};
worker.addEventListener('message', handleMessage as EventListener);
worker.addEventListener('error', handleError as EventListener);
const request: CompareWorkerRequest = {
type: 'compare',
payload: {
baseTokens,
comparisonTokens,
warnings: warningMessages,
// Static worker settings to support large documents
settings: {
batchSize: 5000,
complexThreshold: 120000,
maxWordThreshold: 200000,
},
},
};
worker.postMessage(request);
});
},
[ensureWorker]
);
const executeOperation = useCallback(
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
// start new run
const runId = ++activeRunIdRef.current;
cancelledRef.current = false;
if (!params.baseFileId || !params.comparisonFileId) {
setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.'));
return;
}
const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId)
?? selectors.getFile(params.baseFileId);
const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId)
?? selectors.getFile(params.comparisonFileId);
if (!baseFile || !comparisonFile) {
setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.'));
return;
}
setIsLoading(true);
setStatusState('extracting');
setStatusDetailMs(null);
setErrorMessage(null);
setWarnings([]);
setResult(null);
setFiles([]);
cleanupDownloadUrl();
setDownloadUrl(null);
setDownloadFilename('');
const warningMessages: CompareWorkerWarnings = {
// No accuracy warning any more
tooLargeMessage: t(
'compare.large.file.message',
'These documents are very large; comparison may take several minutes. Please keep this tab open.'
),
emptyTextMessage: t(
'compare.no.text.message',
'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.'
),
tooDissimilarMessage: t(
'compare.too.dissimilar.message',
'These documents appear highly dissimilar. Comparison was stopped to save time.'
),
};
const operationStart = performance.now();
try {
const [baseContent, comparisonContent] = await Promise.all([
extractContentFromPdf(baseFile),
extractContentFromPdf(comparisonFile),
]);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) {
throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' });
}
setStatusState('processing');
// Filter out paragraph sentinels before diffing to avoid large false-positive runs
const baseFiltered = filterTokensForDiff(baseContent.tokens, baseContent.metadata);
const comparisonFiltered = filterTokensForDiff(comparisonContent.tokens, comparisonContent.metadata);
const combinedPageCount =
(baseContent.pageSizes?.length ?? 0) + (comparisonContent.pageSizes?.length ?? 0);
if (
combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD &&
!longRunningToastIdRef.current
) {
const toastId = alert({
alertType: 'neutral',
title: t('compare.longJob.title', 'Large comparison in progress'),
body: t(
'compare.longJob.body',
'These PDFs together exceed 2,000 pages. Processing can take several minutes.'
),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
expandable: false,
});
longRunningToastIdRef.current = toastId || null;
}
// Heuristic: surface an early warning toast when we observe a very high ratio of differences
const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning
const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged
let observedAddedRemoved = 0;
let observedUnchanged = 0;
const handleEarlyDissimilarity = () => {
if (dissimilarityToastShownRef.current || dissimilarityToastIdRef.current) return;
const toastId = alert({
alertType: 'warning',
title: t('compare.earlyDissimilarity.title', 'These PDFs look highly different'),
body: t(
'compare.earlyDissimilarity.body',
"We're seeing very few similarities so far. You can stop the comparison if these aren't related documents."
),
location: 'bottom-right' as ToastLocation,
isPersistentPopup: true,
expandable: false,
buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'),
buttonCallback: () => {
try { cancelOperation(); } catch {
console.error('Failed to cancel operation');
}
try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch {
console.error('Failed to dispatch clear selected event');
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
},
});
dissimilarityToastIdRef.current = toastId || null;
dissimilarityToastShownRef.current = true;
};
const { tokens, stats, warnings: workerWarnings } = await runCompareWorker(
baseFiltered.tokens,
comparisonFiltered.tokens,
warningMessages,
(chunk) => {
// Incremental ratio tracking for early warning
for (const tok of chunk) {
if (tok.type === 'unchanged') observedUnchanged += 1;
else observedAddedRemoved += 1;
}
const seen = observedAddedRemoved + observedUnchanged;
if (
!dissimilarityToastShownRef.current &&
seen >= EARLY_TOAST_MIN_TOKENS &&
observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO
) {
handleEarlyDissimilarity();
}
}
);
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
const baseHasHighlight = new Array<boolean>(baseFiltered.tokens.length).fill(false);
const comparisonHasHighlight = new Array<boolean>(comparisonFiltered.tokens.length).fill(false);
let baseTokenPointer = 0;
let comparisonTokenPointer = 0;
for (const diffToken of tokens) {
if (diffToken.type === 'removed') {
if (baseTokenPointer < baseHasHighlight.length) {
baseHasHighlight[baseTokenPointer] = true;
}
baseTokenPointer += 1;
} else if (diffToken.type === 'added') {
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonHasHighlight[comparisonTokenPointer] = true;
}
comparisonTokenPointer += 1;
} else {
if (baseTokenPointer < baseHasHighlight.length) {
baseTokenPointer += 1;
}
if (comparisonTokenPointer < comparisonHasHighlight.length) {
comparisonTokenPointer += 1;
}
}
}
const buildFilteredTokenData = (
tokensList: typeof baseFiltered.tokens,
metadataList: typeof baseFiltered.metadata,
highlightFlags: boolean[]
): CompareFilteredTokenInfo[] =>
tokensList.map((token, index) => {
const meta = metadataList[index];
return {
token,
page: meta?.page ?? null,
paragraph: meta?.paragraph ?? null,
bbox: meta?.bbox ?? null,
hasHighlight: highlightFlags[index] ?? false,
metaIndex: index,
};
});
const totals = aggregateTotals(tokens);
const processedAt = Date.now();
const baseMetadata = baseFiltered.metadata;
const comparisonMetadata = comparisonFiltered.metadata;
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
const comparisonResult: CompareResultData = {
base: {
fileId: baseFile.fileId,
fileName: baseFile.name,
highlightColor: REMOVAL_HIGHLIGHT,
wordCount: stats.baseWordCount,
pageSizes: baseContent.pageSizes,
},
comparison: {
fileId: comparisonFile.fileId,
fileName: comparisonFile.name,
highlightColor: ADDITION_HIGHLIGHT,
wordCount: stats.comparisonWordCount,
pageSizes: comparisonContent.pageSizes,
},
totals: {
...totals,
durationMs: stats.durationMs,
processedAt,
},
tokens,
tokenMetadata: {
base: baseMetadata,
comparison: comparisonMetadata,
},
filteredTokenData: {
base: buildFilteredTokenData(baseFiltered.tokens, baseFiltered.metadata, baseHasHighlight),
comparison: buildFilteredTokenData(
comparisonFiltered.tokens,
comparisonFiltered.metadata,
comparisonHasHighlight
),
},
sourceTokens: {
base: baseContent.tokens,
comparison: comparisonContent.tokens,
},
changes,
warnings: workerWarnings,
baseParagraphs: baseContent.paragraphs,
comparisonParagraphs: comparisonContent.paragraphs,
};
setResult(comparisonResult);
setWarnings(workerWarnings);
const summaryFile = createSummaryFile(comparisonResult);
setFiles([summaryFile]);
cleanupDownloadUrl();
const blobUrl = URL.createObjectURL(summaryFile);
previousUrl.current = blobUrl;
setDownloadUrl(blobUrl);
setDownloadFilename(summaryFile.name);
setStatusState('complete');
} catch (error: unknown) {
console.error('[compare] operation failed', error);
const errorCode = getWorkerErrorCode(error);
if (errorCode === 'EMPTY_TEXT') {
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
} else {
const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.');
if (error instanceof Error && error.message) {
setErrorMessage(error.message);
} else if (typeof error === 'string' && error.trim().length > 0) {
setErrorMessage(error);
} else {
setErrorMessage(fallbackMessage);
}
}
} finally {
const duration = performance.now() - operationStart;
setStatusDetailMs(Math.round(duration));
setIsLoading(false);
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
if (dissimilarityToastIdRef.current) {
dismissToast(dissimilarityToastIdRef.current);
dissimilarityToastIdRef.current = null;
}
dissimilarityToastShownRef.current = false;
}
},
[cleanupDownloadUrl, runCompareWorker, selectors, t]
);
const cancelOperation = useCallback(() => {
if (!isLoading) return;
cancelledRef.current = true;
setIsLoading(false);
setStatusState('cancelled');
if (workerRef.current) {
try {
workerRef.current.terminate();
// eslint-disable-next-line no-empty
} catch {}
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
}, [isLoading]);
const undoOperation = useCallback(async () => {
resetResults();
}, [resetResults]);
useEffect(() => {
return () => {
cleanupDownloadUrl();
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
}
if (longRunningToastIdRef.current) {
dismissToast(longRunningToastIdRef.current);
longRunningToastIdRef.current = null;
}
};
}, [cleanupDownloadUrl]);
const status = useMemo(() => {
const label =
statusState === 'idle' ? ''
: statusState === 'extracting' ? t('compare.status.extracting', 'Extracting text...')
: statusState === 'processing' ? t('compare.status.processing', 'Analyzing differences...')
: statusState === 'complete' ? t('compare.status.complete', 'Comparison ready')
: statusState === 'cancelled' ? t('operationCancelled', 'Operation cancelled')
: '';
if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`;
return label;
}, [statusState, statusDetailMs, t]);
return useMemo<CompareOperationHook>(
() => ({
files,
thumbnails: [],
isGeneratingThumbnails: false,
downloadUrl,
downloadFilename,
isLoading,
status,
errorMessage,
progress: null,
executeOperation,
resetResults,
clearError,
cancelOperation,
undoOperation,
result,
warnings,
}),
[
cancelOperation,
clearError,
downloadFilename,
downloadUrl,
errorMessage,
executeOperation,
files,
isLoading,
resetResults,
result,
status,
undoOperation,
warnings,
]
);
};

View File

@ -0,0 +1,23 @@
import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
import type { FileId } from '@app/types/file';
export interface CompareParameters {
baseFileId: FileId | null;
comparisonFileId: FileId | null;
}
export const defaultParameters: CompareParameters = {
baseFileId: null,
comparisonFileId: null,
};
export type CompareParametersHook = BaseParametersHook<CompareParameters>;
export const useCompareParameters = (): CompareParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'compare',
validateFn: (params) =>
Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId),
});
};

View File

@ -1,14 +1,16 @@
import { useCallback } from 'react';
import { useFileActions } from '@app/contexts/FileContext';
import type { StirlingFile } from '@app/types/fileContext';
export const useFileHandler = () => {
const { actions } = useFileActions();
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}) => {
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}): Promise<StirlingFile[]> => {
// Merge default options with passed options - passed options take precedence
const mergedOptions = { selectFiles: true, ...options };
// Let FileContext handle deduplication with quickKey logic
await actions.addFiles(files, mergedOptions);
const result = await actions.addFiles(files, mergedOptions);
return result;
}, [actions.addFiles]);
return {

View File

@ -0,0 +1,9 @@
import { useMediaQuery } from '@mantine/hooks';
/**
* Custom hook to detect mobile viewport
* Uses a consistent breakpoint across the application
*/
export const useIsMobile = (): boolean => {
return useMediaQuery('(max-width: 1024px)') ?? false;
};

View File

@ -0,0 +1,279 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
import { PagePreview } from '@app/types/compare';
const DISPLAY_SCALE = 1;
const BATCH_SIZE = 10; // Render 10 pages at a time
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
interface ProgressivePagePreviewsOptions {
file: File | null;
enabled: boolean;
cacheKey: number | null;
visiblePageRange?: { start: number; end: number }; // 0-based page indices
}
interface ProgressivePagePreviewsState {
pages: PagePreview[];
loading: boolean;
totalPages: number;
loadedPages: Set<number>; // 0-based page indices that have been loaded
loadingPages: Set<number>; // 0-based page indices currently being loaded
}
export const useProgressivePagePreviews = ({
file,
enabled,
cacheKey,
visiblePageRange,
}: ProgressivePagePreviewsOptions) => {
const [state, setState] = useState<ProgressivePagePreviewsState>({
pages: [],
loading: false,
totalPages: 0,
loadedPages: new Set(),
loadingPages: new Set(),
});
const pdfRef = useRef<PDFDocumentProxy | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const renderPageBatch = useCallback(async (
pdf: PDFDocumentProxy,
pageNumbers: number[],
signal: AbortSignal
): Promise<PagePreview[]> => {
const previews: PagePreview[] = [];
const dpr = getDevicePixelRatio();
const renderScale = Math.max(2, Math.min(3, dpr * 2));
for (const pageNumber of pageNumbers) {
if (signal.aborted) break;
try {
const page = await pdf.getPage(pageNumber);
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
const renderViewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = Math.round(renderViewport.width);
canvas.height = Math.round(renderViewport.height);
if (!context) {
page.cleanup();
continue;
}
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
previews.push({
pageNumber,
width: Math.round(displayViewport.width),
height: Math.round(displayViewport.height),
rotation: (page.rotate || 0) % 360,
url: canvas.toDataURL(),
});
page.cleanup();
canvas.width = 0;
canvas.height = 0;
} catch (error) {
console.error(`[progressive-pages] failed to render page ${pageNumber}:`, error);
}
}
return previews;
}, []);
const loadPageRange = useCallback(async (
startPage: number,
endPage: number,
signal: AbortSignal
) => {
// Use the live PDF ref for bounds instead of possibly stale state
const totalPages = pdfRef.current?.numPages ?? state.totalPages;
if (startPage < 0 || endPage >= totalPages || startPage > endPage) {
return;
}
// Check which pages need to be loaded
const pagesToLoad: number[] = [];
for (let i = startPage; i <= endPage; i++) {
if (!state.loadedPages.has(i) && !state.loadingPages.has(i)) {
pagesToLoad.push(i + 1); // Convert to 1-based page numbers
}
}
if (pagesToLoad.length === 0) return;
// Mark pages as loading
setState(prev => ({
...prev,
loadingPages: new Set([...prev.loadingPages, ...pagesToLoad.map(p => p - 1)]),
}));
try {
const pdfDoc = pdfRef.current;
if (!pdfDoc) return;
const previews = await renderPageBatch(pdfDoc, pagesToLoad, signal);
if (!signal.aborted) {
setState(prev => {
const newPages = [...prev.pages];
const newLoadedPages = new Set(prev.loadedPages);
const newLoadingPages = new Set(prev.loadingPages);
// Add new previews and mark as loaded
for (const preview of previews) {
const pageIndex = preview.pageNumber - 1; // Convert to 0-based
newLoadedPages.add(pageIndex);
newLoadingPages.delete(pageIndex);
// Insert preview in correct position
const insertIndex = newPages.findIndex(p => p.pageNumber > preview.pageNumber);
if (insertIndex === -1) {
newPages.push(preview);
} else {
newPages.splice(insertIndex, 0, preview);
}
}
return {
...prev,
pages: newPages,
loadedPages: newLoadedPages,
loadingPages: newLoadingPages,
};
});
}
} catch (error) {
if (!signal.aborted) {
console.error('[progressive-pages] failed to load page batch:', error);
}
} finally {
if (!signal.aborted) {
setState(prev => {
const newLoadingPages = new Set(prev.loadingPages);
pagesToLoad.forEach(p => newLoadingPages.delete(p - 1));
return { ...prev, loadingPages: newLoadingPages };
});
}
}
}, [state.loadedPages, state.loadingPages, state.totalPages, renderPageBatch]);
// Initialize PDF and load first batch
useEffect(() => {
let cancelled = false;
if (!file || !enabled) {
setState({
pages: [],
loading: false,
totalPages: 0,
loadedPages: new Set(),
loadingPages: new Set(),
});
return () => {
cancelled = true;
};
}
const initialize = async () => {
try {
setState(prev => ({ ...prev, loading: true }));
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
disableAutoFetch: true,
disableStream: true,
});
if (cancelled) {
pdfWorkerManager.destroyDocument(pdf);
return;
}
pdfRef.current = pdf;
const totalPages = pdf.numPages;
setState(prev => ({
...prev,
totalPages,
loading: false,
}));
// Load first batch of pages using a real abort controller
const initAbort = new AbortController();
const firstBatchEnd = Math.min(BATCH_SIZE - 1, totalPages - 1);
await loadPageRange(0, firstBatchEnd, initAbort.signal);
} catch (error) {
console.error('[progressive-pages] failed to initialize PDF:', error);
if (!cancelled) {
setState(prev => ({
...prev,
loading: false,
totalPages: 0,
}));
}
}
};
initialize();
return () => {
cancelled = true;
if (pdfRef.current) {
pdfWorkerManager.destroyDocument(pdfRef.current);
pdfRef.current = null;
}
};
}, [file, enabled, cacheKey, loadPageRange]);
// Load pages based on visible range
useEffect(() => {
if (!visiblePageRange || state.totalPages === 0) return;
const { start, end } = visiblePageRange;
const startPage = Math.max(0, start - 5); // Add buffer before
const endPage = Math.min(state.totalPages - 1, end + 5); // Add buffer after
// Cancel previous loading
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
loadPageRange(startPage, endPage, abortController.signal);
return () => {
abortController.abort();
};
}, [visiblePageRange, state.totalPages, loadPageRange]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (pdfRef.current) {
pdfWorkerManager.destroyDocument(pdfRef.current);
}
};
}, []);
return {
pages: state.pages,
loading: state.loading,
totalPages: state.totalPages,
loadedPages: state.loadedPages,
loadingPages: state.loadingPages,
};
};
export type UseProgressivePagePreviewsReturn = ReturnType<typeof useProgressivePagePreviews>;

View File

@ -1,9 +1,9 @@
import { useMediaQuery } from '@mantine/hooks';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const isMobile = useMediaQuery("(max-width: 1024px)");
const isMobile = useIsMobile();
return !preferences.hasCompletedOnboarding
&& preferences.toolPanelModePromptSeen

View File

@ -6,7 +6,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { BASE_PATH } from "@app/constants/app";
import { useBaseUrl } from "@app/hooks/useBaseUrl";
import { useMediaQuery } from "@mantine/hooks";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import AppsIcon from '@mui/icons-material/AppsRounded';
@ -45,7 +45,7 @@ export default function HomePage() {
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
const isMobile = useMediaQuery("(max-width: 1024px)");
const isMobile = useIsMobile();
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);

View File

@ -1,3 +1,19 @@
:root {
/* Compare highlight colors (same in light/dark) */
--spdf-compare-removed-bg: rgba(255, 107, 107, 0.45); /* #ff6b6b @ 0.45 */
--spdf-compare-added-bg: rgba(81, 207, 102, 0.35); /* #51cf66 @ 0.35 */
/* Badge colors for dropdowns */
--spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15);
--spdf-compare-removed-badge-fg: #b91c1c;
--spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18);
--spdf-compare-added-badge-fg: #1b5e20;
/* Inline highlights in summary */
--spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25);
--spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25);
}
/* CSS variables for Tailwind + Mantine integration */
:root {
@ -174,6 +190,7 @@
--right-rail-foreground: #E3E4E5; /* panel behind custom tool icons */
--right-rail-icon: #4B5563; /* icon color */
--right-rail-icon-disabled: #CECECE;/* disabled icon */
--right-rail-pan-active-bg: #EAEAEA;
/* Colors for tooltips */
--tooltip-title-bg: #DBEFFF;
@ -284,6 +301,17 @@
--pdf-light-report-container-bg: 249 250 251;
--pdf-light-simulated-page-bg: 255 255 255;
--pdf-light-simulated-page-text: 15 23 42;
/* Compare tool specific colors - only for colors that don't have existing theme pairs */
--compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45);
--compare-upload-dropzone-border: rgba(148, 163, 184, 0.6);
--compare-upload-icon-bg: rgba(148, 163, 184, 0.2);
--compare-upload-icon-color: rgba(17, 24, 39, 0.75);
--compare-upload-divider: rgba(148, 163, 184, 0.5);
/* Compare page label chip (light mode): slightly lighter than surrounding rows */
--compare-page-label-bg: var(--bg-muted);
--compare-page-label-fg: var(--text-secondary);
}
[data-mantine-color-scheme="dark"] {
@ -415,6 +443,7 @@
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
--right-rail-icon: #BCBEBF; /* icon color */
--right-rail-icon-disabled: #43464B;/* disabled icon */
--right-rail-pan-active-bg: #EAEAEA;
/* Dark mode tooltip colors */
--tooltip-title-bg: #4B525A;
@ -428,6 +457,10 @@
--text-brand: var(--color-gray-800);
--text-brand-accent: #EF4444;
/* Compare badge text colors (dark mode): lighter for readability */
--spdf-compare-removed-badge-fg: var(--color-red-500);
--spdf-compare-added-badge-fg: var(--color-green-500);
/* container */
--landing-paper-bg: #171A1F;
--landing-inner-paper-bg: var(--bg-raised);
@ -500,6 +533,17 @@
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
--modal-content-bg: #2A2F36;
--modal-header-border: rgba(255, 255, 255, 0.08);
/* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */
--compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45);
--compare-upload-dropzone-border: rgba(75, 85, 99, 0.6);
--compare-upload-icon-bg: rgba(75, 85, 99, 0.2);
--compare-upload-icon-color: rgba(243, 244, 246, 0.75);
--compare-upload-divider: rgba(75, 85, 99, 0.5);
/* Compare page label chip (dark mode): slightly darker than surrounding rows */
--compare-page-label-bg: #1F2329;
--compare-page-label-fg: var(--text-secondary);
}
/* Dropzone drop state styling */

View File

@ -0,0 +1,596 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
import { Box, Group, Stack, Text, Button, Modal, ActionIcon } from '@mantine/core';
import SwapVertRoundedIcon from '@mui/icons-material/SwapVertRounded';
import AddIcon from '@mui/icons-material/Add';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
import {
useCompareParameters,
defaultParameters as compareDefaultParameters,
} from '@app/hooks/tools/compare/useCompareParameters';
import {
useCompareOperation,
CompareOperationHook,
} from '@app/hooks/tools/compare/useCompareOperation';
import CompareWorkbenchView from '@app/components/tools/compare/CompareWorkbenchView';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useFileContext, useFileState } from '@app/contexts/file/fileHooks';
import type { FileId } from '@app/types/file';
import type { StirlingFile } from '@app/types/fileContext';
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
import type { CompareWorkbenchData } from '@app/types/compare';
import FitText from '@app/components/shared/FitText';
import { getDefaultWorkbench } from '@app/types/workbench';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
const Compare = (props: BaseToolProps) => {
const { t } = useTranslation();
const { actions: navigationActions } = useNavigationActions();
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
const { selectors, actions: fileActions } = useFileContext();
const { state: fileState } = useFileState();
const { openFilesModal } = useFilesModalContext();
const base = useBaseTool(
'compare',
useCompareParameters,
useCompareOperation,
props,
{ minFiles: 2 }
);
const operation = base.operation as CompareOperationHook;
const params = base.params.parameters;
const compareIcon = useMemo(() => <CompareRoundedIcon fontSize="small" />, []);
const [swapConfirmOpen, setSwapConfirmOpen] = useState(false);
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
const performClearSelected = useCallback(() => {
try { base.operation.cancelOperation(); } catch { console.error('Failed to cancel operation'); }
try { base.operation.resetResults(); } catch { console.error('Failed to reset results'); }
base.params.setParameters(prev => ({ ...prev, baseFileId: null, comparisonFileId: null }));
try { fileActions.clearSelections(); } catch { console.error('Failed to clear selections'); }
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
navigationActions.setWorkbench(getDefaultWorkbench());
}, [base.operation, base.params, clearCustomWorkbenchViewData, fileActions, navigationActions]);
useEffect(() => {
const handler = () => {
performClearSelected();
};
window.addEventListener('compare:clear-selected', handler as unknown as EventListener);
return () => {
window.removeEventListener('compare:clear-selected', handler as unknown as EventListener);
};
}, [performClearSelected]);
useEffect(() => {
registerCustomWorkbenchView({
id: CUSTOM_VIEW_ID,
workbenchId: CUSTOM_WORKBENCH_ID,
// Use a static label at registration time to avoid re-registering on i18n changes
label: 'Compare view',
icon: compareIcon,
component: CompareWorkbenchView,
});
return () => {
unregisterCustomWorkbenchView(CUSTOM_VIEW_ID);
};
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
}, []);
// Auto-map from workbench selection: always reflect the first two selected files in order.
// This also handles deselection by promoting the remaining selection to base and clearing comparison.
useEffect(() => {
// Use selected IDs directly from state so it works even if File objects aren't loaded yet
const selectedIds = (fileState.ui.selectedFileIds as FileId[]) ?? [];
// Determine next base: keep current if still selected; otherwise use the first selected id
const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId)
? (params.baseFileId as FileId)
: (selectedIds[0] ?? null);
// Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id
let nextComp: FileId | null = null;
if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) {
nextComp = params.comparisonFileId as FileId;
} else {
nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null;
}
if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) {
base.params.setParameters(prev => ({
...prev,
baseFileId: nextBase,
comparisonFileId: nextComp,
}));
}
}, [fileState.ui.selectedFileIds, base.params, params.baseFileId, params.comparisonFileId]);
// Track workbench data and drive loading/result state transitions
const lastProcessedAtRef = useRef<number | null>(null);
const lastWorkbenchDataRef = useRef<CompareWorkbenchData | null>(null);
const updateWorkbenchData = useCallback(
(data: CompareWorkbenchData) => {
const previous = lastWorkbenchDataRef.current;
if (
previous &&
previous.result === data.result &&
previous.baseFileId === data.baseFileId &&
previous.comparisonFileId === data.comparisonFileId &&
previous.isLoading === data.isLoading &&
previous.baseLocalFile === data.baseLocalFile &&
previous.comparisonLocalFile === data.comparisonLocalFile
) {
return;
}
lastWorkbenchDataRef.current = data;
setCustomWorkbenchViewData(CUSTOM_VIEW_ID, data);
},
[setCustomWorkbenchViewData]
);
const prepareWorkbenchForRun = useCallback(
(
baseId: FileId | null,
compId: FileId | null,
options?: { baseFile?: StirlingFile | null; comparisonFile?: StirlingFile | null }
) => {
if (!baseId || !compId) {
return;
}
const previous = lastWorkbenchDataRef.current;
const resolvedBaseFile =
options?.baseFile ??
(baseId ? selectors.getFile(baseId) : null) ??
previous?.baseLocalFile ??
null;
const resolvedComparisonFile =
options?.comparisonFile ??
(compId ? selectors.getFile(compId) : null) ??
previous?.comparisonLocalFile ??
null;
updateWorkbenchData({
result: null,
baseFileId: baseId,
comparisonFileId: compId,
baseLocalFile: resolvedBaseFile,
comparisonLocalFile: resolvedComparisonFile,
isLoading: true,
});
lastProcessedAtRef.current = null;
},
[selectors, updateWorkbenchData]
);
useEffect(() => {
const baseFileId = params.baseFileId as FileId | null;
const comparisonFileId = params.comparisonFileId as FileId | null;
if (!baseFileId || !comparisonFileId) {
lastProcessedAtRef.current = null;
lastWorkbenchDataRef.current = null;
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
return;
}
const result = operation.result;
const processedAt = result?.totals.processedAt ?? null;
if (
result &&
processedAt !== null &&
processedAt !== lastProcessedAtRef.current &&
result.base.fileId === baseFileId &&
result.comparison.fileId === comparisonFileId
) {
const previous = lastWorkbenchDataRef.current;
const baseLocalFile =
(baseFileId ? selectors.getFile(baseFileId) : null) ??
previous?.baseLocalFile ??
null;
const comparisonLocalFile =
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
previous?.comparisonLocalFile ??
null;
updateWorkbenchData({
result,
baseFileId,
comparisonFileId,
baseLocalFile,
comparisonLocalFile,
isLoading: false,
});
lastProcessedAtRef.current = processedAt;
return;
}
if (base.operation.isLoading) {
const previous = lastWorkbenchDataRef.current;
const baseLocalFile =
(baseFileId ? selectors.getFile(baseFileId) : null) ??
previous?.baseLocalFile ??
null;
const comparisonLocalFile =
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
previous?.comparisonLocalFile ??
null;
updateWorkbenchData({
result: null,
baseFileId,
comparisonFileId,
baseLocalFile,
comparisonLocalFile,
isLoading: true,
});
return;
}
}, [
base.operation.isLoading,
clearCustomWorkbenchViewData,
operation.result,
params.baseFileId,
params.comparisonFileId,
selectors,
updateWorkbenchData,
]);
const handleExecuteCompare = useCallback(async () => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
const baseSel =
base.selectedFiles.find((file) => file.fileId === baseId) ??
(baseId ? selectors.getFile(baseId) : null);
const compSel =
base.selectedFiles.find((file) => file.fileId === compId) ??
(compId ? selectors.getFile(compId) : null);
const selected: StirlingFile[] = [];
if (baseSel) selected.push(baseSel);
if (compSel) selected.push(compSel);
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
if (baseId && compId) {
requestAnimationFrame(() => {
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
});
}
await operation.executeOperation(
{ ...params },
selected
);
}, [base.selectedFiles, navigationActions, operation, params, prepareWorkbenchForRun, selectors]);
// Run compare with explicit ids (used after swap so we don't depend on async state propagation)
const runCompareWithIds = useCallback(async (baseId: FileId | null, compId: FileId | null) => {
const nextParams = { ...params, baseFileId: baseId, comparisonFileId: compId };
const selected: StirlingFile[] = [];
const baseSel =
base.selectedFiles.find((file) => file.fileId === baseId) ??
(baseId ? selectors.getFile(baseId) : null);
const compSel =
base.selectedFiles.find((file) => file.fileId === compId) ??
(compId ? selectors.getFile(compId) : null);
if (baseSel) selected.push(baseSel);
if (compSel) selected.push(compSel);
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
await operation.executeOperation(nextParams, selected);
}, [base.selectedFiles, operation, params, prepareWorkbenchForRun, selectors]);
const performSwap = useCallback(() => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
if (!baseId || !compId) return;
base.params.setParameters((prev) => ({
...prev,
baseFileId: compId,
comparisonFileId: baseId,
}));
if (operation.result) {
runCompareWithIds(compId, baseId);
}
}, [base.params, operation.result, params.baseFileId, params.comparisonFileId, runCompareWithIds]);
// No custom handler; rely on global add flow which auto-selects added files
const handleSwap = useCallback(() => {
const baseId = params.baseFileId as FileId | null;
const compId = params.comparisonFileId as FileId | null;
if (!baseId || !compId) return;
if (operation.result) {
setSwapConfirmOpen(true);
return;
}
performSwap();
}, [operation.result, params.baseFileId, params.comparisonFileId, performSwap]);
const renderSelectedFile = useCallback(
(role: 'base' | 'comparison') => {
const fileId = role === 'base' ? params.baseFileId : params.comparisonFileId;
const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined;
// Show add button in base if no base file, or in comparison if base exists but no comparison
const shouldShowAddButton =
(role === 'base' && !params.baseFileId) ||
(role === 'comparison' && params.baseFileId && !params.comparisonFileId);
if (!stub) {
return (
<Stack gap={6}>
<Box
style={{
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '0.75rem 1rem',
background: 'var(--bg-surface)',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: shouldShowAddButton ? 'pointer' : 'default',
}}
onClick={shouldShowAddButton ? () => openFilesModal({}) : undefined}
>
<Text size="sm" c="dimmed">
{t(
role === 'base' ? 'compare.original.placeholder' : 'compare.edited.placeholder',
role === 'base' ? 'Select the original PDF' : 'Select the edited PDF'
)}
</Text>
{shouldShowAddButton && (
<ActionIcon
variant="filled"
color="blue"
size="sm"
onClick={(e) => {
e.stopPropagation();
openFilesModal({});
}}
style={{ flexShrink: 0 }}
>
<AddIcon fontSize="small" />
</ActionIcon>
)}
</Box>
</Stack>
);
}
// Build compact meta line for pages and date
const dateMs = (stub?.lastModified || stub?.createdAt) ?? null;
const dateText = dateMs
? new Date(dateMs).toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' })
: '';
const pageCount = stub?.processedFile?.totalPages || null;
return (
<Stack gap={6}>
<Box
style={{
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '0.75rem 1rem',
background: 'var(--bg-surface)',
width: '100%',
minHeight: "9rem"
}}
>
<Group align="flex-start" wrap="nowrap" gap="md">
<Box className="compare-tool__thumbnail" style={{ alignSelf: 'center' }}>
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
</Box>
<Stack className="compare-tool__details">
<FitText
text={stub?.name || ''}
minimumFontScale={0.8}
lines={3}
style={{ fontWeight: 600
}}
/>
{pageCount && dateText && (
<>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{pageCount} {t('compare.pages', 'pages')}
<br />
{dateText}
</Text>
</>
)}
</Stack>
</Group>
</Box>
</Stack>
);
},
[params.baseFileId, params.comparisonFileId, selectors, t, openFilesModal]
);
const baseStub = params.baseFileId ? selectors.getStirlingFileStub(params.baseFileId) : undefined;
const compStub = params.comparisonFileId ? selectors.getStirlingFileStub(params.comparisonFileId) : undefined;
const canExecute = Boolean(
params.baseFileId &&
params.comparisonFileId &&
params.baseFileId !== params.comparisonFileId &&
baseStub &&
compStub &&
!base.operation.isLoading &&
base.endpointEnabled !== false
);
const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId);
const hasAnyFiles = selectors.getFiles().length > 0;
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: false,
},
steps: [
{
title: t('compare.selection.originalEditedTitle', 'Select Original and Edited PDFs'),
isVisible: true,
content: (
<Box
style={{
display: 'grid',
gridTemplateColumns: hasBothSelected ? '1fr 2.25rem' : '1fr',
gap: '1rem',
alignItems: 'stretch',
width: '100%',
}}
>
{/* Header row: Original PDF + Clear selected aligned to swap column */}
<Box
style={{ gridColumn: hasBothSelected ? '1 / span 2' : '1', display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.5rem' }}
>
<Text fw={700} size="sm">{t('compare.original.label', 'Original PDF')}</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => setClearConfirmOpen(true)}
disabled={!hasAnyFiles}
styles={{ root: { textDecoration: 'underline' } }}
style={{
background: !hasAnyFiles ? 'transparent' : undefined,
color: !hasAnyFiles ? 'var(--spdf-clear-disabled-text)' : undefined
}}
>
{t('compare.clearSelected', 'Clear selected')}
</Button>
</Box>
<Box
style={{
gridColumn: '1',
minWidth: 0,
}}
>
{renderSelectedFile('base')}
<div style={{ height: '0.75rem' }} />
{/* Edited PDF section header */}
<Text fw={700} size="sm" style={{ marginBottom: '1rem', marginTop: '0.5rem'}}>{t('compare.edited.label', 'Edited PDF')}</Text>
{renderSelectedFile('comparison')}
</Box>
{hasBothSelected && (
<Box
style={{
gridColumn: '2',
gridRow: '2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'stretch',
}}
>
<Button
variant="subtle"
onClick={handleSwap}
disabled={!hasBothSelected || base.operation.isLoading}
style={{
width: '2.25rem',
height: '100%',
padding: 0,
borderRadius: '0.5rem',
background: 'var(--bg-surface)',
border: '1px solid var(--border-default)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<SwapVertRoundedIcon fontSize="medium" />
</Button>
</Box>
)}
<Modal
opened={swapConfirmOpen}
onClose={() => setSwapConfirmOpen(false)}
title={t('compare.swap.confirmTitle', 'Re-run comparison?')}
centered
size="sm"
>
<Stack gap="md">
<Text>{t('compare.swap.confirmBody', 'This will rerun the tool. Are you sure you want to swap the order of Original and Edited?')}</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={() => setSwapConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
<Button
variant="filled"
onClick={() => {
setSwapConfirmOpen(false);
performSwap();
}}
>
{t('compare.swap.confirm', 'Swap and Re-run')}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={clearConfirmOpen}
onClose={() => setClearConfirmOpen(false)}
title={t('compare.clear.confirmTitle', 'Clear selected PDFs?')}
centered
size="sm"
>
<Stack gap="md">
<Text>{t('compare.clear.confirmBody', 'This will close the current comparison and take you back to Active Files.')}</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={() => setClearConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
<Button
variant="filled"
onClick={() => {
setClearConfirmOpen(false);
performClearSelected();
}}
>
{t('compare.clear.confirm', 'Clear and return')}
</Button>
</Group>
</Stack>
</Modal>
</Box>
),
},
],
executeButton: {
text: t('compare.cta', 'Compare'),
loadingText: t('compare.loading', 'Comparing...'),
onClick: handleExecuteCompare,
disabled: !canExecute,
testId: 'compare-execute',
},
review: {
isVisible: false,
operation: base.operation,
title: t('compare.review.title', 'Comparison Result'),
onUndo: base.operation.undoOperation,
},
});
};
const CompareTool = Compare as ToolComponent;
CompareTool.tool = () => useCompareOperation;
CompareTool.getDefaultParameters = () => ({ ...compareDefaultParameters });
export default CompareTool;

View File

@ -39,6 +39,8 @@ const ValidateSignature = (props: BaseToolProps) => {
const hasResults = operation.results.length > 0;
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
useEffect(() => {
registerCustomWorkbenchView({
id: REPORT_VIEW_ID,

View File

@ -0,0 +1,314 @@
import type { FileId } from '@app/types/file';
import type { StirlingFile } from '@app/types/fileContext';
export type CompareDiffTokenType = 'unchanged' | 'removed' | 'added';
export interface CompareDiffToken {
type: CompareDiffTokenType;
text: string;
}
export const REMOVAL_HIGHLIGHT = '#FF3B30';
export const ADDITION_HIGHLIGHT = '#34C759';
export const PARAGRAPH_SENTINEL = '\uE000¶';
export interface TokenBoundingBox {
left: number;
top: number;
width: number;
height: number;
}
export interface CompareTokenMetadata {
page: number;
paragraph: number;
bbox: TokenBoundingBox | null;
}
export interface ComparePageSize {
width: number;
height: number;
}
export interface CompareDocumentInfo {
fileId: string;
fileName: string;
highlightColor: string;
wordCount: number;
pageSizes: ComparePageSize[];
}
export interface CompareParagraph {
page: number;
paragraph: number;
text: string;
}
export interface CompareFilteredTokenInfo {
token: string;
page: number | null;
paragraph: number | null;
bbox: TokenBoundingBox | null;
hasHighlight: boolean;
metaIndex: number;
}
export interface CompareChangeSide {
text: string;
page: number | null;
paragraph: number | null;
}
export interface CompareChange {
id: string;
base: CompareChangeSide | null;
comparison: CompareChangeSide | null;
}
export interface CompareResultData {
base: CompareDocumentInfo;
comparison: CompareDocumentInfo;
totals: {
added: number;
removed: number;
unchanged: number;
durationMs: number;
processedAt: number;
};
tokens: CompareDiffToken[];
tokenMetadata: {
base: CompareTokenMetadata[];
comparison: CompareTokenMetadata[];
};
filteredTokenData: {
base: CompareFilteredTokenInfo[];
comparison: CompareFilteredTokenInfo[];
};
sourceTokens: {
base: string[];
comparison: string[];
};
changes: CompareChange[];
warnings: string[];
baseParagraphs: CompareParagraph[];
comparisonParagraphs: CompareParagraph[];
}
export interface CompareWorkerWarnings {
complexMessage?: string;
tooLargeMessage?: string;
emptyTextMessage?: string;
tooDissimilarMessage?: string;
}
export interface CompareWorkerRequest {
type: 'compare';
payload: {
baseTokens: string[];
comparisonTokens: string[];
warnings: CompareWorkerWarnings;
settings?: {
batchSize?: number;
complexThreshold?: number;
maxWordThreshold?: number;
// Early-stop and runtime controls (optional)
earlyStopEnabled?: boolean;
minJaccardUnigram?: number;
minJaccardBigram?: number;
minTokensForEarlyStop?: number;
sampleLimit?: number;
runtimeMaxProcessedTokens?: number;
runtimeMinUnchangedRatio?: number;
};
};
}
export type CompareWorkerResponse =
| {
type: 'chunk';
tokens: CompareDiffToken[];
}
| {
type: 'success';
stats: {
baseWordCount: number;
comparisonWordCount: number;
durationMs: number;
};
}
| {
type: 'warning';
message: string;
}
| {
type: 'error';
message: string;
code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR';
};
export interface CompareDocumentPaneProps {
pane: 'base' | 'comparison';
layout: 'side-by-side' | 'stacked';
scrollRef: React.RefObject<HTMLDivElement | null>;
peerScrollRef: React.RefObject<HTMLDivElement | null>;
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
handleWheelOverscroll: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent<HTMLDivElement>) => void;
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
onTouchEnd: (event: React.TouchEvent<HTMLDivElement>) => void;
isPanMode: boolean;
zoom: number;
title: string;
dropdownPlaceholder?: React.ReactNode;
changes: Array<{ value: string; label: string; pageNumber?: number }>;
onNavigateChange: (id: string, pageNumber?: number) => void;
isLoading: boolean;
processingMessage: string;
pages: PagePreview[];
pairedPages: PagePreview[];
getRowHeightPx: (pageNumber: number) => number;
wordHighlightMap: Map<number, WordHighlightEntry[]>;
metaIndexToGroupId: Map<number, string>;
documentLabel: string;
pageLabel: string;
altLabel: string;
// Page input/navigation props (optional to keep call sites flexible)
pageInputValue?: string;
onPageInputChange?: (next: string) => void;
maxSharedPages?: number; // min(baseTotal, compTotal)
renderedPageNumbers?: Set<number>;
onVisiblePageChange?: (pane: 'base' | 'comparison', pageNumber: number) => void;
}
// Import types that are referenced in CompareDocumentPaneProps
export interface PagePreview {
pageNumber: number;
width: number;
height: number;
rotation: number;
url: string | null;
}
export interface WordHighlightEntry {
rect: TokenBoundingBox;
metaIndex: number;
}
export interface NavigationDropdownProps {
changes: Array<{ value: string; label: string; pageNumber?: number }>;
placeholder: React.ReactNode;
className?: string;
onNavigate: (value: string, pageNumber?: number) => void;
// Optional: pages that currently have previews rendered (1-based page numbers)
renderedPageNumbers?: Set<number>;
}
// Pan/Zoom and Compare Workbench shared types (moved out of hooks for reuse)
import type React from 'react';
export type ComparePane = 'base' | 'comparison';
export interface PanState {
x: number;
y: number;
}
export interface ScrollLinkDelta {
vertical: number;
horizontal: number;
}
export interface ScrollLinkAnchors {
deltaPixelsBaseToComp: number;
deltaPixelsCompToBase: number;
}
export interface PanDragState {
active: boolean;
source: ComparePane | null;
startX: number;
startY: number;
startPanX: number;
startPanY: number;
targetStartPanX: number;
targetStartPanY: number;
}
export interface PinchState {
active: boolean;
pane: ComparePane | null;
startDistance: number;
startZoom: number;
}
export interface UseComparePanZoomOptions {
prefersStacked: boolean;
basePages: PagePreview[];
comparisonPages: PagePreview[];
}
export interface UseComparePanZoomReturn {
layout: 'side-by-side' | 'stacked';
setLayout: (layout: 'side-by-side' | 'stacked') => void;
toggleLayout: () => void;
baseScrollRef: React.RefObject<HTMLDivElement | null>;
comparisonScrollRef: React.RefObject<HTMLDivElement | null>;
isScrollLinked: boolean;
setIsScrollLinked: (value: boolean) => void;
captureScrollLinkDelta: () => void;
clearScrollLinkDelta: () => void;
isPanMode: boolean;
setIsPanMode: (value: boolean) => void;
baseZoom: number;
setBaseZoom: (value: number) => void;
comparisonZoom: number;
setComparisonZoom: (value: number) => void;
basePan: PanState;
comparisonPan: PanState;
setPanToTopLeft: (pane: ComparePane) => void;
centerPanForZoom: (pane: ComparePane, zoom: number) => void;
clampPanForZoom: (pane: ComparePane, zoom: number) => void;
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
beginPan: (pane: ComparePane, event: React.MouseEvent<HTMLDivElement>) => void;
continuePan: (event: React.MouseEvent<HTMLDivElement>) => void;
endPan: () => void;
handleWheelZoom: (pane: ComparePane, event: React.WheelEvent<HTMLDivElement>) => void;
handleWheelOverscroll: (pane: ComparePane, event: React.WheelEvent<HTMLDivElement>) => void;
onTouchStart: (pane: ComparePane, event: React.TouchEvent<HTMLDivElement>) => void;
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
onTouchEnd: () => void;
zoomLimits: { min: number; max: number; step: number };
}
export interface PagePreview {
pageNumber: number;
width: number;
height: number;
rotation: number;
url: string | null;
}
export interface WordHighlightEntry {
rect: TokenBoundingBox;
metaIndex: number;
}
// Removed legacy upload section types; upload flow now uses the standard active files workbench
export interface CompareWorkbenchData {
result: CompareResultData | null;
baseFileId: FileId | null;
comparisonFileId: FileId | null;
onSelectBase?: (fileId: FileId | null) => void;
onSelectComparison?: (fileId: FileId | null) => void;
isLoading?: boolean;
baseLocalFile?: StirlingFile | null;
comparisonLocalFile?: StirlingFile | null;
}
export interface CompareChangeOption {
value: string;
label: string;
pageNumber: number;
}

View File

@ -86,7 +86,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
}
// Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations)
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations)
if (!exportActions) {
console.error('No export actions available');
return null;

View File

@ -0,0 +1,50 @@
// Shared text diff and normalization utilities for compare tool
export const shouldConcatWithoutSpace = (word: string) => {
return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s";
};
export const appendWord = (existing: string, word: string) => {
if (!existing) return word;
if (shouldConcatWithoutSpace(word)) return `${existing}${word}`;
return `${existing} ${word}`;
};
export const tokenize = (text: string): string[] => text.split(/\s+/).filter(Boolean);
type TokenType = 'unchanged' | 'removed' | 'added';
export interface LocalToken { type: TokenType; text: string }
const buildLcsMatrix = (a: string[], b: string[]) => {
const rows = a.length + 1;
const cols = b.length + 1;
const m: number[][] = new Array(rows);
for (let i = 0; i < rows; i += 1) m[i] = new Array(cols).fill(0);
for (let i = 1; i < rows; i += 1) {
for (let j = 1; j < cols; j += 1) {
m[i][j] = a[i - 1] === b[j - 1] ? m[i - 1][j - 1] + 1 : Math.max(m[i][j - 1], m[i - 1][j]);
}
}
return m;
};
export const diffWords = (a: string[], b: string[]): LocalToken[] => {
const matrix = buildLcsMatrix(a, b);
const tokens: LocalToken[] = [];
let i = a.length;
let j = b.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
tokens.unshift({ type: 'unchanged', text: a[i - 1] });
i -= 1; j -= 1;
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
tokens.unshift({ type: 'added', text: b[j - 1] });
j -= 1;
} else if (i > 0) {
tokens.unshift({ type: 'removed', text: a[i - 1] });
i -= 1;
}
}
return tokens;
};

View File

@ -0,0 +1,452 @@
/// <reference lib="webworker" />
import type {
CompareDiffToken,
CompareWorkerRequest,
CompareWorkerResponse,
} from '@app/types/compare';
declare const self: DedicatedWorkerGlobalScope;
const DEFAULT_SETTINGS = {
batchSize: 5000,
complexThreshold: 25000,
maxWordThreshold: 60000,
// Early stop configuration
earlyStopEnabled: true,
// Jaccard thresholds for quick prefilter (unigram/bigram)
minJaccardUnigram: 0.005,
minJaccardBigram: 0.003,
// Only consider early stop when docs are reasonably large
minTokensForEarlyStop: 20000,
// Sampling cap for similarity estimation
sampleLimit: 50000,
// Runtime stop-loss during chunked diff
runtimeMaxProcessedTokens: 150000,
runtimeMinUnchangedRatio: 0.001,
};
const buildMatrix = (words1: string[], words2: string[]) => {
const rows = words1.length + 1;
const cols = words2.length + 1;
const matrix: number[][] = new Array(rows);
for (let i = 0; i < rows; i += 1) {
matrix[i] = new Array(cols).fill(0);
}
for (let i = 1; i <= words1.length; i += 1) {
for (let j = 1; j <= words2.length; j += 1) {
matrix[i][j] =
words1[i - 1] === words2[j - 1]
? matrix[i - 1][j - 1] + 1
: Math.max(matrix[i][j - 1], matrix[i - 1][j]);
}
}
return matrix;
};
const backtrack = (matrix: number[][], words1: string[], words2: string[]): CompareDiffToken[] => {
const tokens: CompareDiffToken[] = [];
let i = words1.length;
let j = words2.length;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && words1[i - 1] === words2[j - 1]) {
tokens.unshift({ type: 'unchanged', text: words1[i - 1] });
i -= 1;
j -= 1;
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
tokens.unshift({ type: 'added', text: words2[j - 1] });
j -= 1;
} else if (i > 0) {
tokens.unshift({ type: 'removed', text: words1[i - 1] });
i -= 1;
} else {
j -= 1;
}
}
return tokens;
};
const diff = (words1: string[], words2: string[]): CompareDiffToken[] => {
if (words1.length === 0 && words2.length === 0) {
return [];
}
const matrix = buildMatrix(words1, words2);
return backtrack(matrix, words1, words2);
};
const countBaseTokens = (segment: CompareDiffToken[]) =>
segment.reduce((acc, token) => acc + (token.type !== 'added' ? 1 : 0), 0);
const countComparisonTokens = (segment: CompareDiffToken[]) =>
segment.reduce((acc, token) => acc + (token.type !== 'removed' ? 1 : 0), 0);
const findLastUnchangedIndex = (segment: CompareDiffToken[]) => {
for (let i = segment.length - 1; i >= 0; i -= 1) {
if (segment[i].type === 'unchanged') {
return i;
}
}
return -1;
};
const chunkedDiff = (
words1: string[],
words2: string[],
chunkSize: number,
emit: (tokens: CompareDiffToken[]) => void,
runtimeStop?: { maxProcessedTokens: number; minUnchangedRatio: number }
) => {
if (words1.length === 0 && words2.length === 0) {
return;
}
const baseChunkSize = Math.max(1, chunkSize);
let dynamicChunkSize = baseChunkSize;
const baseMaxWindow = Math.max(baseChunkSize * 6, baseChunkSize + 512);
let dynamicMaxWindow = baseMaxWindow;
let dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1));
let dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5));
let stallIterations = 0;
const increaseChunkSizes = () => {
const maxChunkSize = baseChunkSize * 8;
if (dynamicChunkSize >= maxChunkSize) {
return;
}
const nextChunk = Math.min(
maxChunkSize,
Math.max(dynamicChunkSize + dynamicStep, Math.floor(dynamicChunkSize * 1.5))
);
if (nextChunk === dynamicChunkSize) {
return;
}
dynamicChunkSize = nextChunk;
dynamicMaxWindow = Math.max(dynamicMaxWindow, Math.max(dynamicChunkSize * 6, dynamicChunkSize + 512));
dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1));
dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5));
};
let index1 = 0;
let index2 = 0;
let buffer1: string[] = [];
let buffer2: string[] = [];
let totalProcessedBase = 0;
let totalProcessedComp = 0;
let totalUnchanged = 0;
const countUnchanged = (segment: CompareDiffToken[]) =>
segment.reduce((acc, token) => acc + (token.type === 'unchanged' ? 1 : 0), 0);
const flushRemainder = () => {
if (buffer1.length === 0 && buffer2.length === 0) {
return;
}
const finalTokens = diff(buffer1, buffer2);
if (finalTokens.length > 0) {
emit(finalTokens);
}
buffer1 = [];
buffer2 = [];
index1 = words1.length;
index2 = words2.length;
};
while (
index1 < words1.length ||
index2 < words2.length ||
buffer1.length > 0 ||
buffer2.length > 0
) {
const remaining1 = Math.max(0, words1.length - index1);
const remaining2 = Math.max(0, words2.length - index2);
let windowSize = Math.max(dynamicChunkSize, buffer1.length, buffer2.length);
let window1: string[] = [];
let window2: string[] = [];
let chunkTokens: CompareDiffToken[] = [];
let reachedEnd = false;
while (true) {
const take1 = Math.min(Math.max(0, windowSize - buffer1.length), remaining1);
const take2 = Math.min(Math.max(0, windowSize - buffer2.length), remaining2);
const slice1 = take1 > 0 ? words1.slice(index1, index1 + take1) : [];
const slice2 = take2 > 0 ? words2.slice(index2, index2 + take2) : [];
window1 = buffer1.length > 0 ? [...buffer1, ...slice1] : slice1;
window2 = buffer2.length > 0 ? [...buffer2, ...slice2] : slice2;
if (window1.length === 0 && window2.length === 0) {
flushRemainder();
return;
}
chunkTokens = diff(window1, window2);
const lastStableIndex = findLastUnchangedIndex(chunkTokens);
reachedEnd =
index1 + take1 >= words1.length &&
index2 + take2 >= words2.length;
const windowTooLarge =
window1.length >= dynamicMaxWindow ||
window2.length >= dynamicMaxWindow;
if (lastStableIndex >= 0 || reachedEnd || windowTooLarge) {
break;
}
const canGrow1 = take1 < remaining1;
const canGrow2 = take2 < remaining2;
if (!canGrow1 && !canGrow2) {
break;
}
windowSize = Math.min(
dynamicMaxWindow,
windowSize + dynamicStep
);
}
if (chunkTokens.length === 0) {
if (reachedEnd) {
flushRemainder();
return;
}
windowSize = Math.min(windowSize + dynamicStep, dynamicMaxWindow);
stallIterations += 1;
if (stallIterations >= 3) {
increaseChunkSizes();
stallIterations = 0;
}
continue;
}
let commitIndex = reachedEnd ? chunkTokens.length - 1 : findLastUnchangedIndex(chunkTokens);
if (commitIndex < 0) {
commitIndex = reachedEnd
? chunkTokens.length - 1
: Math.min(chunkTokens.length - 1, dynamicMinCommit - 1);
}
const commitTokens = commitIndex >= 0 ? chunkTokens.slice(0, commitIndex + 1) : [];
const baseConsumed = countBaseTokens(commitTokens);
const comparisonConsumed = countComparisonTokens(commitTokens);
if (commitTokens.length > 0) {
emit(commitTokens);
}
const consumedFromNew1 = Math.max(0, baseConsumed - buffer1.length);
const consumedFromNew2 = Math.max(0, comparisonConsumed - buffer2.length);
index1 += consumedFromNew1;
index2 += consumedFromNew2;
buffer1 = window1.slice(baseConsumed);
buffer2 = window2.slice(comparisonConsumed);
// Update runtime counters and early stop if necessary
totalProcessedBase += baseConsumed;
totalProcessedComp += comparisonConsumed;
totalUnchanged += countUnchanged(commitTokens);
if (runtimeStop) {
const processedTotal = totalProcessedBase + totalProcessedComp;
if (processedTotal >= runtimeStop.maxProcessedTokens) {
const unchangedRatio = totalUnchanged / Math.max(1, processedTotal);
if (unchangedRatio < runtimeStop.minUnchangedRatio) {
// Signal early termination for extreme dissimilarity
const err = new Error('EARLY_STOP_TOO_DISSIMILAR');
(err as Error & { __earlyStop?: boolean }).__earlyStop = true;
throw err;
}
}
}
if (reachedEnd) {
flushRemainder();
break;
}
if (commitTokens.length < dynamicMinCommit) {
stallIterations += 1;
} else {
stallIterations = 0;
}
if (commitTokens.length === 0 && buffer1.length + buffer2.length > 0) {
if (buffer1.length > 0 && index1 < words1.length) {
buffer1 = buffer1.slice(1);
index1 += 1;
} else if (buffer2.length > 0 && index2 < words2.length) {
buffer2 = buffer2.slice(1);
index2 += 1;
}
}
if (stallIterations >= 3) {
increaseChunkSizes();
stallIterations = 0;
}
}
flushRemainder();
};
// Fast similarity estimation using sampled unigrams and bigrams with Jaccard
const buildSampledSet = (tokens: string[], sampleLimit: number, ngram: 1 | 2): Set<string> => {
const result = new Set<string>();
if (tokens.length === 0) return result;
const stride = Math.max(1, Math.ceil(tokens.length / sampleLimit));
if (ngram === 1) {
for (let i = 0; i < tokens.length; i += stride) {
const t = tokens[i];
if (t) result.add(t);
}
return result;
}
// ngram === 2
for (let i = 0; i + 1 < tokens.length; i += stride) {
const a = tokens[i];
const b = tokens[i + 1];
if (a && b) result.add(`${a}|${b}`);
}
return result;
};
const jaccard = (a: Set<string>, b: Set<string>): number => {
if (a.size === 0 && b.size === 0) return 1;
if (a.size === 0 || b.size === 0) return 0;
let intersection = 0;
const smaller = a.size <= b.size ? a : b;
const larger = a.size <= b.size ? b : a;
for (const v of smaller) {
if (larger.has(v)) intersection += 1;
}
const union = a.size + b.size - intersection;
return union > 0 ? intersection / union : 0;
};
self.onmessage = (event: MessageEvent<CompareWorkerRequest>) => {
const { data } = event;
if (!data || data.type !== 'compare') {
return;
}
const { baseTokens, comparisonTokens, warnings, settings } = data.payload;
const {
batchSize = DEFAULT_SETTINGS.batchSize,
complexThreshold = DEFAULT_SETTINGS.complexThreshold,
maxWordThreshold = DEFAULT_SETTINGS.maxWordThreshold,
earlyStopEnabled = DEFAULT_SETTINGS.earlyStopEnabled,
minJaccardUnigram = DEFAULT_SETTINGS.minJaccardUnigram,
minJaccardBigram = DEFAULT_SETTINGS.minJaccardBigram,
minTokensForEarlyStop = DEFAULT_SETTINGS.minTokensForEarlyStop,
sampleLimit = DEFAULT_SETTINGS.sampleLimit,
runtimeMaxProcessedTokens = DEFAULT_SETTINGS.runtimeMaxProcessedTokens,
runtimeMinUnchangedRatio = DEFAULT_SETTINGS.runtimeMinUnchangedRatio,
} = settings ?? {};
if (!baseTokens || !comparisonTokens || baseTokens.length === 0 || comparisonTokens.length === 0) {
const response: CompareWorkerResponse = {
type: 'error',
message: warnings.emptyTextMessage ?? 'One or both texts are empty.',
code: 'EMPTY_TEXT',
};
self.postMessage(response);
return;
}
if (baseTokens.length > maxWordThreshold || comparisonTokens.length > maxWordThreshold) {
// For compare tool, do not fail hard; warn and continue with chunked diff
const response: CompareWorkerResponse = {
type: 'warning',
message: warnings.tooLargeMessage ?? 'Documents are too large to compare.',
};
self.postMessage(response);
}
const isComplex = baseTokens.length > complexThreshold || comparisonTokens.length > complexThreshold;
if (isComplex && warnings.complexMessage) {
const warningResponse: CompareWorkerResponse = {
type: 'warning',
message: warnings.complexMessage,
};
self.postMessage(warningResponse);
}
// Quick prefilter to avoid heavy diff on extremely dissimilar large docs
if (earlyStopEnabled && Math.min(baseTokens.length, comparisonTokens.length) >= minTokensForEarlyStop) {
const set1u = buildSampledSet(baseTokens, sampleLimit, 1);
const set2u = buildSampledSet(comparisonTokens, sampleLimit, 1);
const jUni = jaccard(set1u, set2u);
const set1b = buildSampledSet(baseTokens, sampleLimit, 2);
const set2b = buildSampledSet(comparisonTokens, sampleLimit, 2);
const jBi = jaccard(set1b, set2b);
if (jUni < minJaccardUnigram && jBi < minJaccardBigram) {
const response: CompareWorkerResponse = {
type: 'error',
message:
warnings.tooDissimilarMessage ??
'These documents appear highly dissimilar. Comparison was stopped to save time.',
code: 'TOO_DISSIMILAR',
};
self.postMessage(response);
return;
}
}
const start = performance.now();
try {
chunkedDiff(
baseTokens,
comparisonTokens,
batchSize,
(tokens) => {
if (tokens.length === 0) {
return;
}
const response: CompareWorkerResponse = {
type: 'chunk',
tokens,
};
self.postMessage(response);
},
{ maxProcessedTokens: runtimeMaxProcessedTokens, minUnchangedRatio: runtimeMinUnchangedRatio }
);
} catch (err) {
const error = err as Error & { __earlyStop?: boolean };
if (error && (error.__earlyStop || error.message === 'EARLY_STOP_TOO_DISSIMILAR')) {
const response: CompareWorkerResponse = {
type: 'error',
message:
warnings.tooDissimilarMessage ??
'These documents appear highly dissimilar. Comparison was stopped to save time.',
code: 'TOO_DISSIMILAR',
};
self.postMessage(response);
return;
}
throw err;
}
const durationMs = performance.now() - start;
const response: CompareWorkerResponse = {
type: 'success',
stats: {
baseWordCount: baseTokens.length,
comparisonWordCount: comparisonTokens.length,
durationMs,
},
};
self.postMessage(response);
};

View File

@ -1,11 +1,11 @@
import { useMediaQuery } from '@mantine/hooks';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAuth } from '@app/auth/UseSession';
import { useIsMobile } from '@app/hooks/useIsMobile';
export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const { session, loading } = useAuth();
const isMobile = useMediaQuery("(max-width: 1024px)");
const isMobile = useIsMobile();
// Only show welcome modal if user is authenticated (session exists)
// This prevents the modal from showing on login screens when security is enabled