diff --git a/app/core/src/main/resources/static/css/theme/theme.css b/app/core/src/main/resources/static/css/theme/theme.css index e1ef98c83..70958b0eb 100644 --- a/app/core/src/main/resources/static/css/theme/theme.css +++ b/app/core/src/main/resources/static/css/theme/theme.css @@ -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); } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 81e2f0c64..3e50f5cee 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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": { diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 2fa6fab55..8c787ee61 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -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", diff --git a/frontend/public/locales/eu-ES/translation.json b/frontend/public/locales/eu-ES/translation.json index 963783e50..61f942ed5 100644 --- a/frontend/public/locales/eu-ES/translation.json +++ b/frontend/public/locales/eu-ES/translation.json @@ -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", diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index cc6124636..b51152af1 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -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(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 diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index dcd9b5366..4b3d1ebc8 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -81,6 +81,7 @@ export default function Workbench() { switch (currentView) { case "fileEditor": + return ( view.workbenchId === currentView && view.data != null); + + if (customView) { const CustomComponent = customView.component; return ; @@ -154,7 +159,7 @@ export default function Workbench() { return ( 0 ? '3.5rem' : '0'), + overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined, }} > {renderMainContent()} diff --git a/frontend/src/core/components/pageEditor/PageThumbnail.tsx b/frontend/src/core/components/pageEditor/PageThumbnail.tsx index 2546a607e..1a33e779b 100644 --- a/frontend/src/core/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/core/components/pageEditor/PageThumbnail.tsx @@ -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 = ({ 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(null); const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 97f1ee295..be13be2d4 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -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 = ({ opened, onClose }) => { + const [active, setActive] = useState('general'); + const isMobile = useIsMobile(); const navigate = useNavigate(); const location = useLocation(); - const [active, setActive] = useState('general'); - const isMobile = useMediaQuery("(max-width: 1024px)"); const { config } = useAppConfig(); // Extract section from URL path (e.g., /settings/people -> people) diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 77db24d46..502430e53 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -65,6 +65,10 @@ export const Tooltip: React.FC = ({ 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 = ({ (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 = ({ 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 = ({ 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 = ({ 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] diff --git a/frontend/src/core/components/shared/TopControls.tsx b/frontend/src/core/components/shared/TopControls.tsx index 64c78b8f1..e62d00716 100644 --- a/frontend/src/core/components/shared/TopControls.tsx +++ b/frontend/src/core/components/shared/TopControls.tsx @@ -168,7 +168,8 @@ const TopControls = ({ return (
-
+
+ = 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; diff --git a/frontend/src/core/components/toast/ToastRenderer.css b/frontend/src/core/components/toast/ToastRenderer.css index 09eb9e26c..0cf2aac09 100644 --- a/frontend/src/core/components/toast/ToastRenderer.css +++ b/frontend/src/core/components/toast/ToastRenderer.css @@ -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; diff --git a/frontend/src/core/components/toast/ToastRenderer.tsx b/frontend/src/core/components/toast/ToastRenderer.tsx index 825ad3591..09891099f 100644 --- a/frontend/src/core/components/toast/ToastRenderer.tsx +++ b/frontend/src/core/components/toast/ToastRenderer.tsx @@ -8,6 +8,7 @@ const locationToClass: Record = { '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 ( <> diff --git a/frontend/src/core/components/toast/index.ts b/frontend/src/core/components/toast/index.ts index e13ab3801..39f6093ea 100644 --- a/frontend/src/core/components/toast/index.ts +++ b/frontend/src/core/components/toast/index.ts @@ -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 | 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(); } - diff --git a/frontend/src/core/components/toast/types.ts b/frontend/src/core/components/toast/types.ts index aeb0c79a5..764e14c34 100644 --- a/frontend/src/core/components/toast/types.ts +++ b/frontend/src/core/components/toast/types.ts @@ -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 { diff --git a/frontend/src/core/components/tools/ToolPanel.tsx b/frontend/src/core/components/tools/ToolPanel.tsx index 4b6ea3eb4..a61739d00 100644 --- a/frontend/src/core/components/tools/ToolPanel.tsx +++ b/frontend/src/core/components/tools/ToolPanel.tsx @@ -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, diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx new file mode 100644 index 000000000..78f9a4b75 --- /dev/null +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -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(); + 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>(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>(new Map()); + const visiblePageRafRef = useRef(null); + const lastReportedVisiblePageRef = useRef(null); + const pageNodesRef = useRef(null); + const groupedRectsByPage = useMemo(() => { + const out = new Map>(); + 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 ( +
+
+ + + {title} + + + {(changes.length > 0 || Boolean(dropdownPlaceholder)) && ( + new Set(pages.map(p => p.pageNumber)), [pages])} + /> + )} + + +
+ +
{ + 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 }} + > + + {isLoading && ( + + + {processingMessage} + + )} + + {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 ( + <> +
+
+ + {documentLabel} · {pageLabel} {page.pageNumber} + +
+
{ + 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; + } + }} + > +
+ {/* Image layer */} + {altLabel} { + 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)) && ( +
+ +
+ )} + {[...groupedRects.entries()].flatMap(([id, rects]) => + mergeConnectedRects(rects).map((rect, index) => { + const rotation = normalizeRotation(page.rotation); + const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset; + return ( + + ); + }) + )} +
+
+
+ + ); + })} +
+
+
+ ); +}; + +export default CompareDocumentPane; diff --git a/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx b/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx new file mode 100644 index 000000000..ab7a74689 --- /dev/null +++ b/frontend/src/core/components/tools/compare/CompareNavigationDropdown.tsx @@ -0,0 +1,229 @@ +import { Combobox, ScrollArea, useCombobox } from '@mantine/core'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { NavigationDropdownProps } from '@app/types/compare'; + +const CompareNavigationDropdown = ({ + changes, + placeholder, + className, + onNavigate, + renderedPageNumbers, +}: NavigationDropdownProps) => { + const { t } = useTranslation(); + const newLineLabel = t('compare.newLine', 'new-line'); + const combobox = useCombobox({ + onDropdownClose: () => { + combobox.resetSelectedOption(); + // Cache scrollTop so we can restore on next open + const viewport = viewportRef.current; + if (viewport) scrollTopRef.current = viewport.scrollTop; + setIsOpen(false); + }, + onDropdownOpen: () => { + setIsOpen(true); + // Restore scrollTop after mount and rebuild offsets + requestAnimationFrame(() => { + const viewport = viewportRef.current; + if (viewport) viewport.scrollTop = scrollTopRef.current; + const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[]; + groupOffsetsRef.current = headers.map((el) => { + const text = el.textContent || ''; + const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0; + return { top: el.offsetTop, page }; + }); + // Update sticky label based on current scroll position + handleScrollPos({ x: 0, y: scrollTopRef.current }); + }); + }, + }); + + const sanitize = (s: string) => { + // Normalize and remove control/separator characters without regex ranges + return s + .normalize('NFKC') + .split('') + .map(char => { + const code = char.charCodeAt(0); + // Replace control chars (0-31, 127) and special separators with space + if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) { + return ' '; + } + return char; + }) + .join('') + .replace(/\s+/g, ' ') + .trim(); + }; + + const isMeaningful = (s: string) => { + const t = sanitize(s); + + // Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class. + const rx = + (() => { + try { + // Construct at runtime so old engines don’t fail parse-time + return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u'); + } catch { + // Fallback (no Unicode props): letters, digits, and common punctuation/symbols + return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/; + } + })(); + + if (!rx.test(t)) return false; + return t.length > 0; + }; + + + const [query, setQuery] = useState(''); + const viewportRef = useRef(null); + const [stickyPage, setStickyPage] = useState(null); + const groupOffsetsRef = useRef>([]); + 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 ( + { + 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.toggleDropdown()} + > + {placeholder} + +
+
+ + + {/* Header sits outside scroll so it stays fixed at top */} +
+ setQuery(e.currentTarget.value)} + /> +
+ {/* Lazy render the scrollable content only when open */} + {isOpen && ( +
+ + {stickyPage != null && ( +
+ {t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage} + {renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && ( + — {t('compare.rendering.rendering', 'rendering')} + )} +
+ )} + + {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( +
+ {t('compare.summary.pageLabel', 'Page')}{' '}{lastPage} + {renderedPageNumbers && !renderedPageNumbers.has(lastPage) && ( + — {t('compare.rendering.rendering', 'rendering')} + )} +
+ ); + } + nodes.push( + { + onNavigate(item.value, item.pageNumber); + combobox.closeDropdown(); + }} + > +
+ {item.label} +
+
+ ); + } + return nodes; + })() + ) : ( + {t('compare.dropdown.noResults', 'No changes found')} + )} +
+
+
+ )} +
+
+ ); +}; + +export default CompareNavigationDropdown; diff --git a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx new file mode 100644 index 000000000..b7006ae3d --- /dev/null +++ b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -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) + ? ({t('compare.dropdown.deletionsLabel', 'Deletions')} ) + : t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length }); + const comparisonDropdownPlaceholder = (isOperationLoading || !result) + ? ({t('compare.dropdown.additionsLabel', 'Additions')} ) + : 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(null); + const completionTimerRef = useRef(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('1'); + const typingTimerRef = useRef(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 ( + + + +
+ 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} + /> + 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} + /> +
+
+
+ ); +}; + +export default CompareWorkbenchView; diff --git a/frontend/src/core/components/tools/compare/compare.ts b/frontend/src/core/components/tools/compare/compare.ts new file mode 100644 index 000000000..e8b9fcbc9 --- /dev/null +++ b/frontend/src/core/components/tools/compare/compare.ts @@ -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, + pane: 'base' | 'comparison' +): Map => { + const groupedRects = new Map(); + 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); +}; + + diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css new file mode 100644 index 000000000..78e562859 --- /dev/null +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -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; + } +} diff --git a/frontend/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts b/frontend/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts new file mode 100644 index 000000000..f137581e8 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useCompareChangeNavigation.ts @@ -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, + comparisonScrollRef: RefObject, + options?: SuppressOptions, +) => { + return useCallback( + (changeValue: string, pane: Pane, pageNumber?: number) => { + const suppress = (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(); + 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; diff --git a/frontend/src/core/components/tools/compare/hooks/useCompareHighlights.ts b/frontend/src/core/components/tools/compare/hooks/useCompareHighlights.ts new file mode 100644 index 000000000..f06b6b499 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useCompareHighlights.ts @@ -0,0 +1,144 @@ +import { useCallback, useMemo } from 'react'; +import type { + CompareFilteredTokenInfo, + WordHighlightEntry, + CompareResultData, + CompareChangeOption, + PagePreview, +} from '@app/types/compare'; + +interface MetaGroupMap { + base: Map; + comparison: Map; +} + +interface WordHighlightMaps { + base: Map; + comparison: Map; +} + +export interface UseCompareHighlightsResult { + baseWordChanges: CompareChangeOption[]; + comparisonWordChanges: CompareChangeOption[]; + metaIndexToGroupId: MetaGroupMap; + wordHighlightMaps: WordHighlightMaps; + getRowHeightPx: (pageNumber: number) => number; +} + +const buildWordChanges = ( + tokens: CompareFilteredTokenInfo[], + metaIndexToGroupId: Map, + 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 => { + const map = new Map(); + 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(), []); + const comparisonMetaIndexToGroupId = useMemo(() => new Map(), []); + + 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(), + comparison: new Map(), + }; + } + + 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, + }; +}; diff --git a/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts new file mode 100644 index 000000000..19e4c9363 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useComparePagePreviews.ts @@ -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 = new Map(); +const latestVersionMap: Map = 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 => { + 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([]); + 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; diff --git a/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts b/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts new file mode 100644 index 000000000..df5a275b9 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useComparePanZoom.ts @@ -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(null); + const comparisonScrollRef = useRef(null); + const isSyncingRef = useRef(false); + const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false }); + const scrollLinkDeltaRef = useRef({ vertical: 0, horizontal: 0 }); + const scrollLinkAnchorsRef = useRef({ + deltaPixelsBaseToComp: 0, + deltaPixelsCompToBase: 0, + }); + const [isScrollLinked, setIsScrollLinked] = useState(true); + const [isPanMode, setIsPanMode] = useState(false); + const panDragRef = useRef({ + active: false, + source: null, + startX: 0, + startY: 0, + startPanX: 0, + startPanY: 0, + targetStartPanX: 0, + targetStartPanY: 0, + }); + const lastActivePaneRef = useRef('base'); + const [baseZoom, setBaseZoom] = useState(1); + const [comparisonZoom, setComparisonZoom] = useState(1); + const [basePan, setBasePan] = useState({ x: 0, y: 0 }); + const [comparisonPan, setComparisonPan] = useState({ x: 0, y: 0 }); + const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 }); + const pinchRef = useRef({ 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(); + const compMap = new Map(); + 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('.compare-pane__content') ?? + comparisonScrollRef.current?.querySelector('.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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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) => { + 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, + }; +}; diff --git a/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx b/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx new file mode 100644 index 000000000..f87e43ae1 --- /dev/null +++ b/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx @@ -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; + comparisonScrollRef?: React.RefObject; +} + +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(() => [ + { + id: 'compare-toggle-layout', + icon: ( + + ), + 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: , + 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: , + 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: , + 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: ( + + ), + 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; diff --git a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx index 0c39da957..b463b6bb6 100644 --- a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx @@ -87,7 +87,7 @@ const FileStatusIndicator = ({ openFilesModal()} + onClick={() => openFilesModal({})} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} > @@ -122,7 +122,7 @@ const FileStatusIndicator = ({ {getPlaceholder() + " "} openFilesModal()} + onClick={() => openFilesModal({})} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} > diff --git a/frontend/src/core/components/tools/shared/createToolFlow.tsx b/frontend/src/core/components/tools/shared/createToolFlow.tsx index 3ff8a3cc9..1a98d1cb3 100644 --- a/frontend/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/core/components/tools/shared/createToolFlow.tsx @@ -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) diff --git a/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts b/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts index f7502592f..cde2cd33b 100644 --- a/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts +++ b/frontend/src/core/components/tooltips/useOverlayPdfsTips.ts @@ -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.' ) }, { diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 95153f5d2..cabc8352c 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -62,8 +62,8 @@ export function useViewerRightRailButtons() { render: ({ disabled }) => ( { @@ -71,6 +71,7 @@ export function useViewerRightRailButtons() { setIsPanning(prev => !prev); }} disabled={disabled} + style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined} > diff --git a/frontend/src/core/contexts/FilesModalContext.tsx b/frontend/src/core/contexts/FilesModalContext.tsx index 468609f7d..aa1d79d1c 100644 --- a/frontend/src/core/contexts/FilesModalContext.tsx +++ b/frontend/src/core/contexts/FilesModalContext.tsx @@ -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(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(); @@ -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); diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index 2c8670012..7c657506e 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -421,4 +421,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue { throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider'); } return context; -} +} \ No newline at end of file diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index fca3ab9b0..82c02b233 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -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: , 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: , diff --git a/frontend/src/core/hooks/tools/compare/operationUtils.ts b/frontend/src/core/hooks/tools/compare/operationUtils.ts new file mode 100644 index 000000000..552ee8dd3 --- /dev/null +++ b/frontend/src/core/hooks/tools/compare/operationUtils.ts @@ -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 | 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 => { + 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[0]); + const styles: Record = 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); + } +}; + + diff --git a/frontend/src/core/hooks/tools/compare/useCompareOperation.ts b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts new file mode 100644 index 000000000..0dd3706f0 --- /dev/null +++ b/frontend/src/core/hooks/tools/compare/useCompareOperation.ts @@ -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 { + result: CompareResultData | null; + warnings: string[]; +} + +// extractContentFromPdf moved to utils + +export const useCompareOperation = (): CompareOperationHook => { + const { t } = useTranslation(); + const { selectors } = useFileContext(); + const workerRef = useRef(null); + const previousUrl = useRef(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('idle'); + const [statusDetailMs, setStatusDetailMs] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [files, setFiles] = useState([]); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [result, setResult] = useState(null); + const [warnings, setWarnings] = useState([]); + const longRunningToastIdRef = useRef(null); + const dissimilarityToastIdRef = useRef(null); + const dissimilarityToastShownRef = useRef(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) => { + 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(baseFiltered.tokens.length).fill(false); + const comparisonHasHighlight = new Array(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( + () => ({ + 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, + ] + ); +}; diff --git a/frontend/src/core/hooks/tools/compare/useCompareParameters.ts b/frontend/src/core/hooks/tools/compare/useCompareParameters.ts new file mode 100644 index 000000000..98cba8753 --- /dev/null +++ b/frontend/src/core/hooks/tools/compare/useCompareParameters.ts @@ -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; + +export const useCompareParameters = (): CompareParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'compare', + validateFn: (params) => + Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId), + }); +}; diff --git a/frontend/src/core/hooks/useFileHandler.ts b/frontend/src/core/hooks/useFileHandler.ts index 93c7fe286..fc935b029 100644 --- a/frontend/src/core/hooks/useFileHandler.ts +++ b/frontend/src/core/hooks/useFileHandler.ts @@ -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 => { // 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 { diff --git a/frontend/src/core/hooks/useIsMobile.ts b/frontend/src/core/hooks/useIsMobile.ts new file mode 100644 index 000000000..aab30293c --- /dev/null +++ b/frontend/src/core/hooks/useIsMobile.ts @@ -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; +}; diff --git a/frontend/src/core/hooks/useProgressivePagePreviews.ts b/frontend/src/core/hooks/useProgressivePagePreviews.ts new file mode 100644 index 000000000..ef62af4fa --- /dev/null +++ b/frontend/src/core/hooks/useProgressivePagePreviews.ts @@ -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; // 0-based page indices that have been loaded + loadingPages: Set; // 0-based page indices currently being loaded +} + +export const useProgressivePagePreviews = ({ + file, + enabled, + cacheKey, + visiblePageRange, +}: ProgressivePagePreviewsOptions) => { + const [state, setState] = useState({ + pages: [], + loading: false, + totalPages: 0, + loadedPages: new Set(), + loadingPages: new Set(), + }); + + const pdfRef = useRef(null); + const abortControllerRef = useRef(null); + + const renderPageBatch = useCallback(async ( + pdf: PDFDocumentProxy, + pageNumbers: number[], + signal: AbortSignal + ): Promise => { + 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; diff --git a/frontend/src/core/hooks/useShouldShowWelcomeModal.ts b/frontend/src/core/hooks/useShouldShowWelcomeModal.ts index 7de00567a..953150fb8 100644 --- a/frontend/src/core/hooks/useShouldShowWelcomeModal.ts +++ b/frontend/src/core/hooks/useShouldShowWelcomeModal.ts @@ -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 diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 7c4d3a6c2..6df688e55 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -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(null); const [activeMobileView, setActiveMobileView] = useState("tools"); const isProgrammaticScroll = useRef(false); diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index c69fcd3d3..22c90d090 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -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 */ diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx new file mode 100644 index 000000000..a2a78a036 --- /dev/null +++ b/frontend/src/core/tools/Compare.tsx @@ -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(() => , []); + 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(null); + const lastWorkbenchDataRef = useRef(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 ( + + openFilesModal({}) : undefined} + > + + {t( + role === 'base' ? 'compare.original.placeholder' : 'compare.edited.placeholder', + role === 'base' ? 'Select the original PDF' : 'Select the edited PDF' + )} + + {shouldShowAddButton && ( + { + e.stopPropagation(); + openFilesModal({}); + }} + style={{ flexShrink: 0 }} + > + + + )} + + + ); + } + // 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 ( + + + + + + + + + {pageCount && dateText && ( + <> + + {pageCount} {t('compare.pages', 'pages')} +
+ {dateText} +
+ + )} +
+
+
+
+ ); + }, + [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: ( + + {/* Header row: Original PDF + Clear selected aligned to swap column */} + + {t('compare.original.label', 'Original PDF')} + + + + {renderSelectedFile('base')} +
+ {/* Edited PDF section header */} + {t('compare.edited.label', 'Edited PDF')} + {renderSelectedFile('comparison')} + + {hasBothSelected && ( + + + + )} + setSwapConfirmOpen(false)} + title={t('compare.swap.confirmTitle', 'Re-run comparison?')} + centered + size="sm" + > + + {t('compare.swap.confirmBody', 'This will rerun the tool. Are you sure you want to swap the order of Original and Edited?')} + + + + + + + setClearConfirmOpen(false)} + title={t('compare.clear.confirmTitle', 'Clear selected PDFs?')} + centered + size="sm" + > + + {t('compare.clear.confirmBody', 'This will close the current comparison and take you back to Active Files.')} + + + + + + + + ), + }, + ], + 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; + + + diff --git a/frontend/src/core/tools/ValidateSignature.tsx b/frontend/src/core/tools/ValidateSignature.tsx index 5ad21c83d..30fab68d9 100644 --- a/frontend/src/core/tools/ValidateSignature.tsx +++ b/frontend/src/core/tools/ValidateSignature.tsx @@ -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, diff --git a/frontend/src/core/types/compare.ts b/frontend/src/core/types/compare.ts new file mode 100644 index 000000000..65b6ea12c --- /dev/null +++ b/frontend/src/core/types/compare.ts @@ -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; + peerScrollRef: React.RefObject; + handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void; + handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent) => void; + handleWheelOverscroll: (pane: 'base' | 'comparison', event: React.WheelEvent) => void; + onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent) => void; + onTouchMove: (event: React.TouchEvent) => void; + onTouchEnd: (event: React.TouchEvent) => 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; + metaIndexToGroupId: Map; + 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; + 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; +} + +// 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; + comparisonScrollRef: React.RefObject; + 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) => void; + continuePan: (event: React.MouseEvent) => void; + endPan: () => void; + handleWheelZoom: (pane: ComparePane, event: React.WheelEvent) => void; + handleWheelOverscroll: (pane: ComparePane, event: React.WheelEvent) => void; + onTouchStart: (pane: ComparePane, event: React.TouchEvent) => void; + onTouchMove: (event: React.TouchEvent) => 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; +} \ No newline at end of file diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index 2baf7586b..25c187020 100644 --- a/frontend/src/core/utils/signatureFlattening.ts +++ b/frontend/src/core/utils/signatureFlattening.ts @@ -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; diff --git a/frontend/src/core/utils/textDiff.ts b/frontend/src/core/utils/textDiff.ts new file mode 100644 index 000000000..fcf36a7d0 --- /dev/null +++ b/frontend/src/core/utils/textDiff.ts @@ -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; +}; + + diff --git a/frontend/src/core/workers/compareWorker.ts b/frontend/src/core/workers/compareWorker.ts new file mode 100644 index 000000000..0e3a323e1 --- /dev/null +++ b/frontend/src/core/workers/compareWorker.ts @@ -0,0 +1,452 @@ +/// + +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 => { + const result = new Set(); + 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, b: Set): 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) => { + 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); +}; diff --git a/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts b/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts index 270188bc8..da03a7215 100644 --- a/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts +++ b/frontend/src/proprietary/hooks/useShouldShowWelcomeModal.ts @@ -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