mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Feature/v2/compare tool (#4751)
# Description of Changes - Addition of the compare tool - --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
f22f697edc
commit
a5e2b54274
@@ -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-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-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%);
|
--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 */
|
/* Icon fill */
|
||||||
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
|
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
|
||||||
--md-sys-icon-fill-1: 'FILL' 1, '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);
|
--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 {
|
.fill {
|
||||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"language": {
|
"language": {
|
||||||
"direction": "ltr"
|
"direction": "ltr"
|
||||||
},
|
},
|
||||||
|
"cancel": "Cancel",
|
||||||
"addPageNumbers": {
|
"addPageNumbers": {
|
||||||
"fontSize": "Font Size",
|
"fontSize": "Font Size",
|
||||||
"fontName": "Font Name",
|
"fontName": "Font Name",
|
||||||
@@ -2157,15 +2158,99 @@
|
|||||||
"tags": "differentiate,contrast,changes,analysis",
|
"tags": "differentiate,contrast,changes,analysis",
|
||||||
"title": "Compare",
|
"title": "Compare",
|
||||||
"header": "Compare PDFs",
|
"header": "Compare PDFs",
|
||||||
"highlightColor": {
|
"clearSelected": "Clear selected",
|
||||||
"1": "Highlight Colour 1:",
|
"clear": {
|
||||||
"2": "Highlight Colour 2:"
|
"confirmTitle": "Clear selected PDFs?",
|
||||||
|
"confirmBody": "This will close the current comparison and take you back to Active Files.",
|
||||||
|
"confirm": "Clear and return"
|
||||||
},
|
},
|
||||||
"document": {
|
"review": {
|
||||||
"1": "Document 1",
|
"title": "Comparison Result",
|
||||||
"2": "Document 2"
|
"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": {
|
"complex": {
|
||||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||||
},
|
},
|
||||||
@@ -2178,6 +2263,16 @@
|
|||||||
"text": {
|
"text": {
|
||||||
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
|
"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": {
|
"certSign": {
|
||||||
|
|||||||
@@ -2443,15 +2443,99 @@
|
|||||||
"tags": "differentiate,contrast,changes,analysis",
|
"tags": "differentiate,contrast,changes,analysis",
|
||||||
"title": "Compare",
|
"title": "Compare",
|
||||||
"header": "Compare PDFs",
|
"header": "Compare PDFs",
|
||||||
"highlightColor": {
|
"clearSelected": "Clear selected",
|
||||||
"1": "Highlight Color 1:",
|
"clear": {
|
||||||
"2": "Highlight Color 2:"
|
"confirmTitle": "Clear selected PDFs?",
|
||||||
|
"confirmBody": "This will close the current comparison and take you back to Active Files.",
|
||||||
|
"confirm": "Clear and return"
|
||||||
},
|
},
|
||||||
"document": {
|
"review": {
|
||||||
"1": "Document 1",
|
"title": "Comparison Result",
|
||||||
"2": "Document 2"
|
"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": {
|
"complex": {
|
||||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||||
},
|
},
|
||||||
@@ -2464,6 +2548,16 @@
|
|||||||
"text": {
|
"text": {
|
||||||
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
|
"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": {
|
"certSign": {
|
||||||
@@ -3115,7 +3209,7 @@
|
|||||||
"title": "Overlay PDFs",
|
"title": "Overlay PDFs",
|
||||||
"desc": "Overlay one PDF on top of another",
|
"desc": "Overlay one PDF on top of another",
|
||||||
"baseFile": {
|
"baseFile": {
|
||||||
"label": "Select Base PDF File"
|
"label": "Select Original PDF File"
|
||||||
},
|
},
|
||||||
"overlayFiles": {
|
"overlayFiles": {
|
||||||
"label": "Select Overlay PDF Files",
|
"label": "Select Overlay PDF Files",
|
||||||
|
|||||||
@@ -2986,7 +2986,7 @@
|
|||||||
"title": "Overlay PDFs",
|
"title": "Overlay PDFs",
|
||||||
"desc": "Overlay one PDF on top of another",
|
"desc": "Overlay one PDF on top of another",
|
||||||
"baseFile": {
|
"baseFile": {
|
||||||
"label": "Select Base PDF File"
|
"label": "Select Original PDF File"
|
||||||
},
|
},
|
||||||
"overlayFiles": {
|
"overlayFiles": {
|
||||||
"label": "Select Overlay PDF Files",
|
"label": "Select Overlay PDF Files",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
|
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 { alert } from '@app/components/toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||||
@@ -64,7 +64,7 @@ const FileEditorThumbnail = ({
|
|||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
const isMobile = useIsMobile();
|
||||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export default function Workbench() {
|
|||||||
|
|
||||||
switch (currentView) {
|
switch (currentView) {
|
||||||
case "fileEditor":
|
case "fileEditor":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileEditor
|
<FileEditor
|
||||||
toolMode={!!selectedToolId}
|
toolMode={!!selectedToolId}
|
||||||
@@ -98,6 +99,7 @@ export default function Workbench() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "viewer":
|
case "viewer":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Viewer
|
<Viewer
|
||||||
sidebarsVisible={sidebarsVisible}
|
sidebarsVisible={sidebarsVisible}
|
||||||
@@ -110,6 +112,7 @@ export default function Workbench() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "pageEditor":
|
case "pageEditor":
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageEditor
|
<PageEditor
|
||||||
@@ -143,6 +146,8 @@ export default function Workbench() {
|
|||||||
default:
|
default:
|
||||||
if (!isBaseWorkbench(currentView)) {
|
if (!isBaseWorkbench(currentView)) {
|
||||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||||
|
|
||||||
|
|
||||||
if (customView) {
|
if (customView) {
|
||||||
const CustomComponent = customView.component;
|
const CustomComponent = customView.component;
|
||||||
return <CustomComponent data={customView.data} />;
|
return <CustomComponent data={customView.data} />;
|
||||||
@@ -154,7 +159,7 @@ export default function Workbench() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
className="flex-1 h-full min-w-0 relative flex flex-col"
|
||||||
data-tour="workbench"
|
data-tour="workbench"
|
||||||
style={
|
style={
|
||||||
isRainbowMode
|
isRainbowMode
|
||||||
@@ -182,10 +187,11 @@ export default function Workbench() {
|
|||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<Box
|
<Box
|
||||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
className={`flex-1 min-h-0 relative z-10 ${currentView === 'viewer' || !isBaseWorkbench(currentView) ? '' : styles.workbenchScrollable}`}
|
||||||
style={{
|
style={{
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||||
|
overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { Text, Checkbox } from '@mantine/core';
|
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 ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||||
@@ -69,7 +69,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
const isMobile = useIsMobile();
|
||||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||||
import { useMediaQuery } from '@mantine/hooks';
|
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||||
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
import '@app/components/shared/AppConfigModal.css';
|
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';
|
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
|
|
||||||
interface AppConfigModalProps {
|
interface AppConfigModalProps {
|
||||||
@@ -15,10 +15,10 @@ interface AppConfigModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||||
|
const [active, setActive] = useState<NavKey>('general');
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [active, setActive] = useState<NavKey>('general');
|
|
||||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
|
|
||||||
// Extract section from URL path (e.g., /settings/people -> people)
|
// Extract section from URL path (e.g., /settings/people -> people)
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
const clickPendingRef = useRef(false);
|
const clickPendingRef = useRef(false);
|
||||||
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
|
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(() => {
|
const clearTimers = useCallback(() => {
|
||||||
if (openTimeoutRef.current) {
|
if (openTimeoutRef.current) {
|
||||||
clearTimeout(openTimeoutRef.current);
|
clearTimeout(openTimeoutRef.current);
|
||||||
@@ -103,9 +107,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
const tEl = tooltipRef.current;
|
const tEl = tooltipRef.current;
|
||||||
const trg = triggerRef.current;
|
const trg = triggerRef.current;
|
||||||
const target = e.target as Node | null;
|
const target = e.target as unknown;
|
||||||
const insideTooltip = tEl && target && tEl.contains(target);
|
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
|
||||||
const insideTrigger = trg && target && trg.contains(target);
|
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
|
||||||
|
|
||||||
// If pinned: only close when clicking outside BOTH tooltip & trigger
|
// If pinned: only close when clicking outside BOTH tooltip & trigger
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
@@ -172,7 +176,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
const related = e.relatedTarget as Node | null;
|
const related = e.relatedTarget as Node | null;
|
||||||
|
|
||||||
// Moving into the tooltip → keep open
|
// 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);
|
(children.props as any)?.onPointerLeave?.(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,7 +240,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
const handleBlur = useCallback(
|
const handleBlur = useCallback(
|
||||||
(e: React.FocusEvent) => {
|
(e: React.FocusEvent) => {
|
||||||
const related = e.relatedTarget as Node | null;
|
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);
|
(children.props as any)?.onBlur?.(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -258,7 +262,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
const handleTooltipPointerLeave = useCallback(
|
const handleTooltipPointerLeave = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
const related = e.relatedTarget as Node | null;
|
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);
|
if (!isPinned) setOpen(false);
|
||||||
},
|
},
|
||||||
[isPinned, setOpen]
|
[isPinned, setOpen]
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ const TopControls = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||||
<div className="flex justify-center mt-[0.5rem]">
|
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
|
||||||
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
data-tour="view-switcher"
|
data-tour="view-switcher"
|
||||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||||
|
|||||||
@@ -93,13 +93,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
progress,
|
progress,
|
||||||
} as ToastInstance;
|
} as ToastInstance;
|
||||||
|
|
||||||
// Detect completion
|
// Detect completion but do not auto-flip to success.
|
||||||
|
// Callers (e.g., compare workbench) explicitly set alertType when done.
|
||||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||||
// On completion: finalize type as success unless explicitly provided otherwise
|
next.justCompleted = true;
|
||||||
next.justCompleted = false;
|
|
||||||
if (!updates.alertType) {
|
|
||||||
next.alertType = 'success';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -31,6 +31,13 @@
|
|||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-container--bottom-center {
|
||||||
|
bottom: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toast Item Styles */
|
/* Toast Item Styles */
|
||||||
.toast-item {
|
.toast-item {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const locationToClass: Record<ToastLocation, string> = {
|
|||||||
'top-right': 'toast-container--top-right',
|
'top-right': 'toast-container--top-right',
|
||||||
'bottom-left': 'toast-container--bottom-left',
|
'bottom-left': 'toast-container--bottom-left',
|
||||||
'bottom-right': 'toast-container--bottom-right',
|
'bottom-right': 'toast-container--bottom-right',
|
||||||
|
'bottom-center': 'toast-container--bottom-center',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getToastItemClass(t: ToastInstance): string {
|
function getToastItemClass(t: ToastInstance): string {
|
||||||
@@ -44,7 +45,7 @@ export default function ToastRenderer() {
|
|||||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||||
acc[key].push(t);
|
acc[key].push(t);
|
||||||
return acc;
|
return acc;
|
||||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ToastOptions } from '@app/components/toast/types';
|
import { ToastApi, ToastInstance, ToastOptions } from '@app/components/toast/types';
|
||||||
import { useToast, ToastProvider } from '@app/components/toast/ToastContext';
|
import { useToast, ToastProvider } from '@app/components/toast/ToastContext';
|
||||||
import ToastRenderer from '@app/components/toast/ToastRenderer';
|
import ToastRenderer from '@app/components/toast/ToastRenderer';
|
||||||
|
|
||||||
@@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer };
|
|||||||
// Global imperative API via module singleton
|
// Global imperative API via module singleton
|
||||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||||
|
|
||||||
|
type ToastContextApi = ToastApi & { toasts: ToastInstance[] };
|
||||||
|
|
||||||
function createImperativeApi() {
|
function createImperativeApi() {
|
||||||
const subscribers: Array<(fn: any) => void> = [];
|
const subscribers: Array<(fn: ToastContextApi) => void> = [];
|
||||||
let api: any = null;
|
let api: ToastContextApi | null = null;
|
||||||
return {
|
return {
|
||||||
provide(instance: any) {
|
provide(instance: ToastContextApi) {
|
||||||
api = instance;
|
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();
|
_api?.get()?.dismissAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode } from 'react';
|
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 type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||||
|
|
||||||
export interface ToastOptions {
|
export interface ToastOptions {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
|
|||||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||||
import { ActionIcon, ScrollArea } from '@mantine/core';
|
import { ActionIcon, ScrollArea } from '@mantine/core';
|
||||||
import { ToolId } from '@app/types/toolId';
|
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 DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface';
|
import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface';
|
||||||
@@ -26,7 +26,7 @@ export default function ToolPanel() {
|
|||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const { sidebarRefs } = useSidebarContext();
|
const { sidebarRefs } = useSidebarContext();
|
||||||
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
||||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
leftPanelView,
|
leftPanelView,
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { Group, Loader, Stack, Text } from '@mantine/core';
|
||||||
|
import { useMemo, useRef, useEffect } from 'react';
|
||||||
|
import type { PagePreview } from '@app/types/compare';
|
||||||
|
import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare';
|
||||||
|
import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare';
|
||||||
|
import CompareNavigationDropdown from '@app/components/tools/compare/CompareNavigationDropdown';
|
||||||
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||||
|
|
||||||
|
// utilities moved to compare.ts
|
||||||
|
|
||||||
|
const CompareDocumentPane = ({
|
||||||
|
pane,
|
||||||
|
layout,
|
||||||
|
scrollRef,
|
||||||
|
peerScrollRef,
|
||||||
|
handleScrollSync,
|
||||||
|
handleWheelZoom,
|
||||||
|
handleWheelOverscroll,
|
||||||
|
onTouchStart,
|
||||||
|
onTouchMove,
|
||||||
|
onTouchEnd,
|
||||||
|
isPanMode,
|
||||||
|
zoom,
|
||||||
|
title,
|
||||||
|
dropdownPlaceholder,
|
||||||
|
changes,
|
||||||
|
onNavigateChange,
|
||||||
|
isLoading,
|
||||||
|
processingMessage,
|
||||||
|
pages,
|
||||||
|
pairedPages,
|
||||||
|
wordHighlightMap,
|
||||||
|
metaIndexToGroupId,
|
||||||
|
documentLabel,
|
||||||
|
pageLabel,
|
||||||
|
altLabel,
|
||||||
|
onVisiblePageChange,
|
||||||
|
}: CompareDocumentPaneProps) => {
|
||||||
|
const isMobileViewport = useIsMobile();
|
||||||
|
const pairedPageMap = useMemo(() => {
|
||||||
|
const map = new Map<number, PagePreview>();
|
||||||
|
pairedPages.forEach((item) => {
|
||||||
|
map.set(item.pageNumber, item);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [pairedPages]);
|
||||||
|
|
||||||
|
const HIGHLIGHT_BG_VAR = pane === 'base' ? 'var(--spdf-compare-removed-bg)' : 'var(--spdf-compare-added-bg)';
|
||||||
|
const OFFSET_PIXELS = pane === 'base' ? 4 : 2;
|
||||||
|
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
|
||||||
|
const pagePanRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||||
|
const dragRef = useRef<{ active: boolean; page: number | null; startX: number; startY: number; startPanX: number; startPanY: number }>({ active: false, page: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 });
|
||||||
|
|
||||||
|
// Track which page images have finished loading to avoid flashing between states
|
||||||
|
const imageLoadedRef = useRef<Map<number, boolean>>(new Map());
|
||||||
|
const visiblePageRafRef = useRef<number | null>(null);
|
||||||
|
const lastReportedVisiblePageRef = useRef<number | null>(null);
|
||||||
|
const pageNodesRef = useRef<HTMLElement[] | null>(null);
|
||||||
|
const groupedRectsByPage = useMemo(() => {
|
||||||
|
const out = new Map<number, Map<string, TokenBoundingBox[]>>();
|
||||||
|
for (const p of pages) {
|
||||||
|
const rects = wordHighlightMap.get(p.pageNumber) ?? [];
|
||||||
|
out.set(p.pageNumber, groupWordRects(rects, metaIndexToGroupId, pane));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [pages, wordHighlightMap, metaIndexToGroupId, pane]);
|
||||||
|
|
||||||
|
// When zoom returns to 1 (reset), clear per-page pan state so content is centered again
|
||||||
|
useEffect(() => {
|
||||||
|
if (zoom <= 1) {
|
||||||
|
pagePanRef.current.clear();
|
||||||
|
}
|
||||||
|
}, [zoom]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compare-pane">
|
||||||
|
<div className="compare-header">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||||
|
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||||
|
<CompareNavigationDropdown
|
||||||
|
changes={changes}
|
||||||
|
placeholder={dropdownPlaceholder ?? null}
|
||||||
|
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
|
||||||
|
onNavigate={onNavigateChange}
|
||||||
|
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={(event) => {
|
||||||
|
handleScrollSync(event.currentTarget, peerScrollRef.current);
|
||||||
|
// Notify parent about the currently visible page (throttled via rAF)
|
||||||
|
if (visiblePageRafRef.current != null) return;
|
||||||
|
if (!onVisiblePageChange || pages.length === 0) return;
|
||||||
|
visiblePageRafRef.current = requestAnimationFrame(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const mid = container.scrollTop + container.clientHeight * 0.5;
|
||||||
|
let bestPage = pages[0]?.pageNumber ?? 1;
|
||||||
|
let bestDist = Number.POSITIVE_INFINITY;
|
||||||
|
let nodes = pageNodesRef.current;
|
||||||
|
if (!nodes || nodes.length !== pages.length) {
|
||||||
|
nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[];
|
||||||
|
pageNodesRef.current = nodes;
|
||||||
|
}
|
||||||
|
for (const el of nodes) {
|
||||||
|
const top = el.offsetTop;
|
||||||
|
const height = el.clientHeight || 1;
|
||||||
|
const center = top + height / 2;
|
||||||
|
const dist = Math.abs(center - mid);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
const attr = el.getAttribute('data-page-number');
|
||||||
|
const pn = attr ? parseInt(attr, 10) : NaN;
|
||||||
|
if (!Number.isNaN(pn)) bestPage = pn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof onVisiblePageChange === 'function' && bestPage !== lastReportedVisiblePageRef.current) {
|
||||||
|
lastReportedVisiblePageRef.current = bestPage;
|
||||||
|
onVisiblePageChange(pane, bestPage);
|
||||||
|
}
|
||||||
|
visiblePageRafRef.current = null;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseDown={undefined}
|
||||||
|
onMouseMove={undefined}
|
||||||
|
onMouseUp={undefined}
|
||||||
|
onMouseLeave={undefined}
|
||||||
|
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
|
||||||
|
onTouchStart={(event) => onTouchStart(pane, event)}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
className="compare-pane__scroll"
|
||||||
|
style={{ cursor: cursorStyle }}
|
||||||
|
>
|
||||||
|
<Stack gap={zoom <= 0.6 ? 2 : zoom <= 0.85 ? 'xs' : 'sm'} className="compare-pane__content">
|
||||||
|
{isLoading && (
|
||||||
|
<Group justify="center" gap="xs" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm">{processingMessage}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pages.map((page) => {
|
||||||
|
const peerPage = pairedPageMap.get(page.pageNumber);
|
||||||
|
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
||||||
|
const metrics = computePageLayoutMetrics({
|
||||||
|
page,
|
||||||
|
peerPage: peerPage ?? null,
|
||||||
|
layout,
|
||||||
|
isMobileViewport,
|
||||||
|
scrollRefWidth: scrollRef.current?.clientWidth ?? null,
|
||||||
|
viewportWidth,
|
||||||
|
zoom,
|
||||||
|
offsetPixels: OFFSET_PIXELS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics;
|
||||||
|
|
||||||
|
// Compute clamped pan for current zoom so content always touches edges when in bounds
|
||||||
|
const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
|
||||||
|
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||||
|
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||||
|
const maxPanX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||||
|
const maxPanY = Math.max(0, contentHeight - Math.round(containerHeight));
|
||||||
|
const clampedPanX = Math.max(0, Math.min(maxPanX, storedPan.x));
|
||||||
|
const clampedPanY = Math.max(0, Math.min(maxPanY, storedPan.y));
|
||||||
|
|
||||||
|
const groupedRects = groupedRectsByPage.get(page.pageNumber) ?? new Map();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="compare-diff-page"
|
||||||
|
data-page-number={page.pageNumber}
|
||||||
|
style={{ minHeight: `${containerHeight}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="compare-page-title"
|
||||||
|
style={{ width: `${containerWidth}px`, marginLeft: 'auto', marginRight: 'auto' }}
|
||||||
|
>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" ta="center">
|
||||||
|
{documentLabel} · {pageLabel} {page.pageNumber}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
|
||||||
|
style={{ width: `${containerWidth}px`, height: `${containerHeight}px`, marginLeft: 'auto', marginRight: 'auto', overflow: 'hidden' }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (!isPanMode || zoom <= 1) return;
|
||||||
|
dragRef.current.active = true;
|
||||||
|
dragRef.current.page = page.pageNumber;
|
||||||
|
dragRef.current.startX = e.clientX;
|
||||||
|
dragRef.current.startY = e.clientY;
|
||||||
|
const curr = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
|
||||||
|
dragRef.current.startPanX = curr.x;
|
||||||
|
dragRef.current.startPanY = curr.y;
|
||||||
|
(e.currentTarget as HTMLElement).style.cursor = 'grabbing';
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
if (!dragRef.current.active || dragRef.current.page !== page.pageNumber) return;
|
||||||
|
const dx = e.clientX - dragRef.current.startX;
|
||||||
|
const dy = e.clientY - dragRef.current.startY;
|
||||||
|
// Clamp panning based on the actual rendered content size.
|
||||||
|
// The inner layer is width/height of the container, then scaled by innerScale.
|
||||||
|
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||||
|
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||||
|
const maxX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||||
|
const maxY = Math.max(0, contentHeight - Math.round(containerHeight));
|
||||||
|
const candX = dragRef.current.startPanX - dx;
|
||||||
|
const candY = dragRef.current.startPanY - dy;
|
||||||
|
const next = { x: Math.max(0, Math.min(maxX, candX)), y: Math.max(0, Math.min(maxY, candY)) };
|
||||||
|
pagePanRef.current.set(page.pageNumber, next);
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onMouseUp={(e) => {
|
||||||
|
if (dragRef.current.active) {
|
||||||
|
dragRef.current.active = false;
|
||||||
|
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (dragRef.current.active) {
|
||||||
|
dragRef.current.active = false;
|
||||||
|
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
|
||||||
|
style={{
|
||||||
|
transform: `scale(${innerScale}) translate3d(${-((clampedPanX) / innerScale)}px, ${-((clampedPanY) / innerScale)}px, 0)`,
|
||||||
|
transformOrigin: 'top left'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Image layer */}
|
||||||
|
<img
|
||||||
|
src={page.url ?? ''}
|
||||||
|
alt={altLabel}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className="compare-diff-page__image"
|
||||||
|
onLoad={() => {
|
||||||
|
if (!imageLoadedRef.current.get(page.pageNumber)) {
|
||||||
|
imageLoadedRef.current.set(page.pageNumber, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Overlay loader until the page image is loaded */}
|
||||||
|
{!((imageLoadedRef.current.get(page.pageNumber) ?? false)) && (
|
||||||
|
<div className="compare-page-loader-overlay">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{[...groupedRects.entries()].flatMap(([id, rects]) =>
|
||||||
|
mergeConnectedRects(rects).map((rect, index) => {
|
||||||
|
const rotation = normalizeRotation(page.rotation);
|
||||||
|
const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${pane}-highlight-${page.pageNumber}-${id}-${index}`}
|
||||||
|
data-change-id={id}
|
||||||
|
className="compare-diff-highlight"
|
||||||
|
style={{
|
||||||
|
left: `${rect.left * 100}%`,
|
||||||
|
top: `${(rect.top + verticalOffset) * 100}%`,
|
||||||
|
width: `${rect.width * 100}%`,
|
||||||
|
height: `${rect.height * 100}%`,
|
||||||
|
backgroundColor: HIGHLIGHT_BG_VAR,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompareDocumentPane;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { NavigationDropdownProps } from '@app/types/compare';
|
||||||
|
|
||||||
|
const CompareNavigationDropdown = ({
|
||||||
|
changes,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
onNavigate,
|
||||||
|
renderedPageNumbers,
|
||||||
|
}: NavigationDropdownProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const newLineLabel = t('compare.newLine', 'new-line');
|
||||||
|
const combobox = useCombobox({
|
||||||
|
onDropdownClose: () => {
|
||||||
|
combobox.resetSelectedOption();
|
||||||
|
// Cache scrollTop so we can restore on next open
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (viewport) scrollTopRef.current = viewport.scrollTop;
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
|
onDropdownOpen: () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
// Restore scrollTop after mount and rebuild offsets
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (viewport) viewport.scrollTop = scrollTopRef.current;
|
||||||
|
const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[];
|
||||||
|
groupOffsetsRef.current = headers.map((el) => {
|
||||||
|
const text = el.textContent || '';
|
||||||
|
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||||
|
return { top: el.offsetTop, page };
|
||||||
|
});
|
||||||
|
// Update sticky label based on current scroll position
|
||||||
|
handleScrollPos({ x: 0, y: scrollTopRef.current });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitize = (s: string) => {
|
||||||
|
// Normalize and remove control/separator characters without regex ranges
|
||||||
|
return s
|
||||||
|
.normalize('NFKC')
|
||||||
|
.split('')
|
||||||
|
.map(char => {
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
// Replace control chars (0-31, 127) and special separators with space
|
||||||
|
if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) {
|
||||||
|
return ' ';
|
||||||
|
}
|
||||||
|
return char;
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMeaningful = (s: string) => {
|
||||||
|
const t = sanitize(s);
|
||||||
|
|
||||||
|
// Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class.
|
||||||
|
const rx =
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
// Construct at runtime so old engines don’t fail parse-time
|
||||||
|
return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u');
|
||||||
|
} catch {
|
||||||
|
// Fallback (no Unicode props): letters, digits, and common punctuation/symbols
|
||||||
|
return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!rx.test(t)) return false;
|
||||||
|
return t.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [stickyPage, setStickyPage] = useState<number | null>(null);
|
||||||
|
const groupOffsetsRef = useRef<Array<{ top: number; page: number }>>([]);
|
||||||
|
const scrollTopRef = useRef(0);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const normalizedChanges = useMemo(() => {
|
||||||
|
// Helper to strip localized new-line marker occurrences from labels
|
||||||
|
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const stripNewLine = (s: string) =>
|
||||||
|
s
|
||||||
|
.replace(new RegExp(`\\b${esc(newLineLabel)}\\b`, 'gi'), ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const cleaned = changes
|
||||||
|
.map((c) => ({ value: c.value, label: stripNewLine(sanitize(c.label)), pageNumber: c.pageNumber }))
|
||||||
|
.filter((c) => isMeaningful(c.label) && c.label.length > 0 && c.label.toLowerCase() !== newLineLabel.toLowerCase());
|
||||||
|
const q = sanitize(query).toLowerCase();
|
||||||
|
if (!q) return cleaned;
|
||||||
|
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
|
||||||
|
}, [changes, query, newLineLabel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Build offsets for group headers whenever list changes while open
|
||||||
|
if (!isOpen) return;
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[];
|
||||||
|
groupOffsetsRef.current = headers.map((el) => {
|
||||||
|
const text = el.textContent || '';
|
||||||
|
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||||
|
return { top: el.offsetTop, page };
|
||||||
|
});
|
||||||
|
// Update sticky based on current scroll position
|
||||||
|
handleScrollPos({ x: 0, y: viewport.scrollTop });
|
||||||
|
}, [normalizedChanges, isOpen]);
|
||||||
|
|
||||||
|
const handleScrollPos = ({ y }: { x: number; y: number }) => {
|
||||||
|
const offsets = groupOffsetsRef.current;
|
||||||
|
if (offsets.length === 0) return;
|
||||||
|
// Find the last header whose top is <= scroll, so the next header replaces it
|
||||||
|
let low = 0;
|
||||||
|
let high = offsets.length - 1;
|
||||||
|
let idx = 0;
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = (low + high) >> 1;
|
||||||
|
if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary
|
||||||
|
idx = mid;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const page = offsets[idx]?.page ?? offsets[0].page;
|
||||||
|
if (page !== stickyPage) setStickyPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
store={combobox}
|
||||||
|
withinPortal={false}
|
||||||
|
onOptionSubmit={(value) => {
|
||||||
|
const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber;
|
||||||
|
onNavigate(value, pn);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
}}
|
||||||
|
// Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly
|
||||||
|
>
|
||||||
|
<Combobox.Target>
|
||||||
|
<div
|
||||||
|
className={['compare-changes-select', className].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => combobox.toggleDropdown()}
|
||||||
|
>
|
||||||
|
<span className="compare-changes-select__placeholder">{placeholder}</span>
|
||||||
|
<Combobox.Chevron />
|
||||||
|
</div>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown className="compare-changes-dropdown">
|
||||||
|
{/* Header sits outside scroll so it stays fixed at top */}
|
||||||
|
<div>
|
||||||
|
<Combobox.Search
|
||||||
|
placeholder={t('compare.dropdown.searchPlaceholder', 'Search changes...')}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Lazy render the scrollable content only when open */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="compare-dropdown-scrollwrap">
|
||||||
|
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
|
||||||
|
{stickyPage != null && (
|
||||||
|
<div className="compare-dropdown-sticky" style={{ top: 0 }}>
|
||||||
|
{t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage}
|
||||||
|
{renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && (
|
||||||
|
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Combobox.Options className="compare-dropdown-options">
|
||||||
|
{normalizedChanges.length > 0 ? (
|
||||||
|
(() => {
|
||||||
|
const nodes: React.ReactNode[] = [];
|
||||||
|
let lastPage: number | null = null;
|
||||||
|
for (const item of normalizedChanges) {
|
||||||
|
if (item.pageNumber && item.pageNumber !== lastPage) {
|
||||||
|
lastPage = item.pageNumber;
|
||||||
|
nodes.push(
|
||||||
|
<div
|
||||||
|
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
|
||||||
|
key={`group-${lastPage}`}
|
||||||
|
>
|
||||||
|
{t('compare.summary.pageLabel', 'Page')}{' '}{lastPage}
|
||||||
|
{renderedPageNumbers && !renderedPageNumbers.has(lastPage) && (
|
||||||
|
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nodes.push(
|
||||||
|
<Combobox.Option
|
||||||
|
value={item.value}
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => {
|
||||||
|
onNavigate(item.value, item.pageNumber);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="compare-dropdown-option">
|
||||||
|
<span className="compare-dropdown-option__text">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
</Combobox.Option>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<Combobox.Empty>{t('compare.dropdown.noResults', 'No changes found')}</Combobox.Empty>
|
||||||
|
)}
|
||||||
|
</Combobox.Options>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompareNavigationDropdown;
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import { Loader, Stack } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||||
|
import {
|
||||||
|
mapChangesForDropdown,
|
||||||
|
getFileFromSelection,
|
||||||
|
getStubFromSelection,
|
||||||
|
computeShowProgressBanner,
|
||||||
|
computeProgressPct,
|
||||||
|
computeCountsText,
|
||||||
|
computeMaxSharedPages,
|
||||||
|
} from '@app/components/tools/compare/compare';
|
||||||
|
import {
|
||||||
|
CompareResultData,
|
||||||
|
CompareWorkbenchData,
|
||||||
|
} from '@app/types/compare';
|
||||||
|
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||||
|
import { useRightRailButtons } from '@app/hooks/useRightRailButtons';
|
||||||
|
import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane';
|
||||||
|
import { useComparePagePreviews } from '@app/components/tools/compare/hooks/useComparePagePreviews';
|
||||||
|
import { useComparePanZoom } from '@app/components/tools/compare/hooks/useComparePanZoom';
|
||||||
|
import { useCompareHighlights } from '@app/components/tools/compare/hooks/useCompareHighlights';
|
||||||
|
import { useCompareChangeNavigation } from '@app/components/tools/compare/hooks/useCompareChangeNavigation';
|
||||||
|
import '@app/components/tools/compare/compareView.css';
|
||||||
|
import { useCompareRightRailButtons } from '@app/components/tools/compare/hooks/useCompareRightRailButtons';
|
||||||
|
import { alert, updateToast, updateToastProgress, dismissToast } from '@app/components/toast';
|
||||||
|
import type { ToastLocation } from '@app/components/toast/types';
|
||||||
|
|
||||||
|
interface CompareWorkbenchViewProps {
|
||||||
|
data: CompareWorkbenchData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers moved to compare.ts
|
||||||
|
|
||||||
|
const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const prefersStacked = useIsMobile();
|
||||||
|
const { selectors } = useFileContext();
|
||||||
|
|
||||||
|
const result: CompareResultData | null = data?.result ?? null;
|
||||||
|
const baseFileId = data?.baseFileId ?? null;
|
||||||
|
const comparisonFileId = data?.comparisonFileId ?? null;
|
||||||
|
const isOperationLoading = data?.isLoading ?? false;
|
||||||
|
|
||||||
|
const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors);
|
||||||
|
const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors);
|
||||||
|
const baseStub = getStubFromSelection(baseFileId, selectors);
|
||||||
|
const comparisonStub = getStubFromSelection(comparisonFileId, selectors);
|
||||||
|
|
||||||
|
const processedAt = result?.totals.processedAt ?? null;
|
||||||
|
|
||||||
|
const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({
|
||||||
|
file: baseFile,
|
||||||
|
enabled: Boolean(result && baseFile),
|
||||||
|
cacheKey: processedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({
|
||||||
|
file: comparisonFile,
|
||||||
|
enabled: Boolean(result && comparisonFile),
|
||||||
|
cacheKey: processedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
layout,
|
||||||
|
toggleLayout,
|
||||||
|
baseScrollRef,
|
||||||
|
comparisonScrollRef,
|
||||||
|
handleScrollSync,
|
||||||
|
handleWheelZoom,
|
||||||
|
handleWheelOverscroll,
|
||||||
|
onTouchStart,
|
||||||
|
onTouchMove,
|
||||||
|
onTouchEnd,
|
||||||
|
isPanMode,
|
||||||
|
setIsPanMode,
|
||||||
|
baseZoom,
|
||||||
|
setBaseZoom,
|
||||||
|
comparisonZoom,
|
||||||
|
setComparisonZoom,
|
||||||
|
setPanToTopLeft,
|
||||||
|
centerPanForZoom,
|
||||||
|
clampPanForZoom,
|
||||||
|
clearScrollLinkDelta,
|
||||||
|
captureScrollLinkDelta,
|
||||||
|
setIsScrollLinked,
|
||||||
|
isScrollLinked,
|
||||||
|
zoomLimits,
|
||||||
|
} = useComparePanZoom({
|
||||||
|
basePages,
|
||||||
|
comparisonPages,
|
||||||
|
prefersStacked,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
baseWordChanges,
|
||||||
|
comparisonWordChanges,
|
||||||
|
metaIndexToGroupId,
|
||||||
|
wordHighlightMaps,
|
||||||
|
getRowHeightPx,
|
||||||
|
} = useCompareHighlights(result, basePages, comparisonPages);
|
||||||
|
|
||||||
|
const temporarilySuppressScrollLink = useCallback((fn: () => void, durationMs = 700) => {
|
||||||
|
const wasLinked = isScrollLinked;
|
||||||
|
if (wasLinked) setIsScrollLinked(false);
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (wasLinked) {
|
||||||
|
// recapture anchors to keep panes aligned when relinking
|
||||||
|
captureScrollLinkDelta();
|
||||||
|
setIsScrollLinked(true);
|
||||||
|
}
|
||||||
|
}, Math.max(200, durationMs));
|
||||||
|
}
|
||||||
|
}, [isScrollLinked, setIsScrollLinked, captureScrollLinkDelta]);
|
||||||
|
|
||||||
|
const handleChangeNavigation = useCompareChangeNavigation(
|
||||||
|
baseScrollRef,
|
||||||
|
comparisonScrollRef,
|
||||||
|
{ temporarilySuppressScrollLink }
|
||||||
|
);
|
||||||
|
|
||||||
|
const processingMessage = t('compare.status.processing', 'Analyzing differences...');
|
||||||
|
const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document');
|
||||||
|
const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document');
|
||||||
|
const pageLabel = t('compare.summary.pageLabel', 'Page');
|
||||||
|
|
||||||
|
// Always show the selected file names from the sidebar; they are known before diff results
|
||||||
|
const baseTitle = baseStub?.name || result?.base?.fileName || '';
|
||||||
|
const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || '';
|
||||||
|
|
||||||
|
// During diff processing, show compact spinners in the dropdown badges
|
||||||
|
const baseDropdownPlaceholder = (isOperationLoading || !result)
|
||||||
|
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.deletionsLabel', 'Deletions')} <Loader size="xs" color="currentColor" /></span>)
|
||||||
|
: t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length });
|
||||||
|
const comparisonDropdownPlaceholder = (isOperationLoading || !result)
|
||||||
|
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.additionsLabel', 'Additions')} <Loader size="xs" color="currentColor" /></span>)
|
||||||
|
: t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length });
|
||||||
|
|
||||||
|
const rightRailButtons = useCompareRightRailButtons({
|
||||||
|
layout,
|
||||||
|
toggleLayout,
|
||||||
|
isPanMode,
|
||||||
|
setIsPanMode,
|
||||||
|
baseZoom,
|
||||||
|
comparisonZoom,
|
||||||
|
setBaseZoom,
|
||||||
|
setComparisonZoom,
|
||||||
|
setPanToTopLeft,
|
||||||
|
centerPanForZoom,
|
||||||
|
clampPanForZoom,
|
||||||
|
clearScrollLinkDelta,
|
||||||
|
captureScrollLinkDelta,
|
||||||
|
isScrollLinked,
|
||||||
|
setIsScrollLinked,
|
||||||
|
zoomLimits,
|
||||||
|
baseScrollRef,
|
||||||
|
comparisonScrollRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
useRightRailButtons(rightRailButtons);
|
||||||
|
|
||||||
|
// Rendering progress toast for very large PDFs
|
||||||
|
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
||||||
|
const totalsKnown = (baseTotal ?? 0) > 0 && (compTotal ?? 0) > 0;
|
||||||
|
const showProgressBanner = useMemo(() => (
|
||||||
|
computeShowProgressBanner(totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading, LARGE_PAGE_THRESHOLD)
|
||||||
|
), [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]);
|
||||||
|
|
||||||
|
const progressPct = computeProgressPct(totalsKnown, baseTotal, compTotal, baseRendered, compRendered);
|
||||||
|
|
||||||
|
const progressToastIdRef = useRef<string | null>(null);
|
||||||
|
const completionTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (completionTimerRef.current != null) {
|
||||||
|
window.clearTimeout(completionTimerRef.current);
|
||||||
|
completionTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (progressToastIdRef.current) {
|
||||||
|
dismissToast(progressToastIdRef.current);
|
||||||
|
progressToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allDone = useMemo(() => {
|
||||||
|
const baseDone = (baseTotal || basePages.length) > 0 && baseRendered >= (baseTotal || basePages.length);
|
||||||
|
const compDone = (compTotal || comparisonPages.length) > 0 && compRendered >= (compTotal || comparisonPages.length);
|
||||||
|
return baseDone && compDone;
|
||||||
|
}, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]);
|
||||||
|
|
||||||
|
// Drive toast lifecycle and progress updates
|
||||||
|
useEffect(() => {
|
||||||
|
// No toast needed
|
||||||
|
if (!showProgressBanner) {
|
||||||
|
if (progressToastIdRef.current) {
|
||||||
|
dismissToast(progressToastIdRef.current);
|
||||||
|
progressToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countsText = computeCountsText(
|
||||||
|
baseRendered,
|
||||||
|
baseTotal,
|
||||||
|
basePages.length,
|
||||||
|
compRendered,
|
||||||
|
compTotal,
|
||||||
|
comparisonPages.length,
|
||||||
|
);
|
||||||
|
if (!allDone) {
|
||||||
|
// Create toast if missing
|
||||||
|
if (!progressToastIdRef.current) {
|
||||||
|
const id = alert({
|
||||||
|
alertType: 'neutral',
|
||||||
|
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||||
|
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||||
|
location: 'bottom-right' as ToastLocation,
|
||||||
|
isPersistentPopup: true,
|
||||||
|
durationMs: 0,
|
||||||
|
expandable: false,
|
||||||
|
progressBarPercentage: progressPct,
|
||||||
|
});
|
||||||
|
progressToastIdRef.current = id;
|
||||||
|
} else {
|
||||||
|
updateToast(progressToastIdRef.current, {
|
||||||
|
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||||
|
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||||
|
location: 'bottom-right' as ToastLocation,
|
||||||
|
isPersistentPopup: true,
|
||||||
|
alertType: 'neutral', // ensure it stays neutral until completion
|
||||||
|
});
|
||||||
|
updateToastProgress(progressToastIdRef.current, progressPct);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Completed: update then auto-dismiss after 3s
|
||||||
|
if (progressToastIdRef.current) {
|
||||||
|
updateToast(progressToastIdRef.current, {
|
||||||
|
title: t('compare.rendering.complete', 'Page rendering complete'),
|
||||||
|
body: undefined,
|
||||||
|
isPersistentPopup: false,
|
||||||
|
durationMs: 3000,
|
||||||
|
});
|
||||||
|
updateToastProgress(progressToastIdRef.current, 100);
|
||||||
|
if (completionTimerRef.current != null) window.clearTimeout(completionTimerRef.current);
|
||||||
|
completionTimerRef.current = window.setTimeout(() => {
|
||||||
|
if (progressToastIdRef.current) {
|
||||||
|
dismissToast(progressToastIdRef.current);
|
||||||
|
progressToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
if (completionTimerRef.current != null) {
|
||||||
|
window.clearTimeout(completionTimerRef.current);
|
||||||
|
completionTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (completionTimerRef.current != null) {
|
||||||
|
window.clearTimeout(completionTimerRef.current);
|
||||||
|
completionTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
|
||||||
|
|
||||||
|
// Shared page navigation state/input
|
||||||
|
const maxSharedPages = useMemo(() => (
|
||||||
|
computeMaxSharedPages(baseTotal, compTotal, basePages.length, comparisonPages.length)
|
||||||
|
), [baseTotal, compTotal, basePages.length, comparisonPages.length]);
|
||||||
|
|
||||||
|
const [pageInputValue, setPageInputValue] = useState<string>('1');
|
||||||
|
const typingTimerRef = useRef<number | null>(null);
|
||||||
|
const isTypingRef = useRef(false);
|
||||||
|
|
||||||
|
// Clamp the displayed input if max changes smaller than current
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pageInputValue) return;
|
||||||
|
const n = Math.max(1, parseInt(pageInputValue, 10) || 1);
|
||||||
|
if (maxSharedPages > 0 && n > maxSharedPages) {
|
||||||
|
setPageInputValue(String(maxSharedPages));
|
||||||
|
}
|
||||||
|
}, [maxSharedPages]);
|
||||||
|
|
||||||
|
const scrollBothToPage = useCallback((pageNum: number) => {
|
||||||
|
const scrollOne = (container: HTMLDivElement | null) => {
|
||||||
|
if (!container) return false;
|
||||||
|
const pageEl = container.querySelector(`.compare-diff-page[data-page-number="${pageNum}"]`) as HTMLElement | null;
|
||||||
|
if (!pageEl) return false;
|
||||||
|
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
|
const desired = Math.max(0, Math.min(maxTop, pageEl.offsetTop - Math.round(container.clientHeight * 0.2)));
|
||||||
|
container.scrollTop = desired;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hitBase = scrollOne(baseScrollRef.current);
|
||||||
|
const hitComp = scrollOne(comparisonScrollRef.current);
|
||||||
|
|
||||||
|
// Warn if one or both pages are not yet rendered
|
||||||
|
const baseHas = basePages.some(p => p.pageNumber === pageNum);
|
||||||
|
const compHas = comparisonPages.some(p => p.pageNumber === pageNum);
|
||||||
|
if (!baseHas || !compHas) {
|
||||||
|
alert({
|
||||||
|
alertType: 'warning',
|
||||||
|
title: t('compare.rendering.pageNotReadyTitle', 'Page not rendered yet'),
|
||||||
|
body: t('compare.rendering.pageNotReadyBody', 'Some pages are still rendering. Navigation will snap once they are ready.'),
|
||||||
|
location: 'bottom-right' as ToastLocation,
|
||||||
|
isPersistentPopup: false,
|
||||||
|
durationMs: 2500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hitBase || hitComp;
|
||||||
|
}, [basePages, comparisonPages, baseScrollRef, comparisonScrollRef, t]);
|
||||||
|
|
||||||
|
const handleTypingChange = useCallback((next: string) => {
|
||||||
|
// Only digits; allow empty while editing
|
||||||
|
const digits = next.replace(/[^0-9]/g, '');
|
||||||
|
if (digits.length === 0) {
|
||||||
|
setPageInputValue('');
|
||||||
|
if (typingTimerRef.current != null) {
|
||||||
|
window.clearTimeout(typingTimerRef.current);
|
||||||
|
typingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Math.max(1, parseInt(digits, 10));
|
||||||
|
const capped = maxSharedPages > 0 ? Math.min(parsed, maxSharedPages) : parsed;
|
||||||
|
const display = String(capped);
|
||||||
|
setPageInputValue(display);
|
||||||
|
|
||||||
|
isTypingRef.current = true;
|
||||||
|
if (typingTimerRef.current != null) window.clearTimeout(typingTimerRef.current);
|
||||||
|
typingTimerRef.current = window.setTimeout(() => {
|
||||||
|
isTypingRef.current = false;
|
||||||
|
scrollBothToPage(capped);
|
||||||
|
}, 300);
|
||||||
|
}, [maxSharedPages, scrollBothToPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="compare-workbench">
|
||||||
|
|
||||||
|
<Stack gap="lg" className="compare-workbench__content">
|
||||||
|
<div
|
||||||
|
className={`compare-workbench__columns ${layout === 'stacked' ? 'compare-workbench__columns--stacked' : ''}`}
|
||||||
|
>
|
||||||
|
<CompareDocumentPane
|
||||||
|
pane="base"
|
||||||
|
layout={layout}
|
||||||
|
scrollRef={baseScrollRef}
|
||||||
|
peerScrollRef={comparisonScrollRef}
|
||||||
|
handleScrollSync={handleScrollSync}
|
||||||
|
handleWheelZoom={handleWheelZoom}
|
||||||
|
handleWheelOverscroll={handleWheelOverscroll}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
isPanMode={isPanMode}
|
||||||
|
zoom={baseZoom}
|
||||||
|
title={baseTitle}
|
||||||
|
dropdownPlaceholder={baseDropdownPlaceholder}
|
||||||
|
changes={mapChangesForDropdown(baseWordChanges)}
|
||||||
|
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)}
|
||||||
|
isLoading={isOperationLoading || baseLoading}
|
||||||
|
processingMessage={processingMessage}
|
||||||
|
pages={basePages}
|
||||||
|
pairedPages={comparisonPages}
|
||||||
|
getRowHeightPx={getRowHeightPx}
|
||||||
|
wordHighlightMap={wordHighlightMaps.base}
|
||||||
|
metaIndexToGroupId={metaIndexToGroupId.base}
|
||||||
|
documentLabel={baseDocumentLabel}
|
||||||
|
pageLabel={pageLabel}
|
||||||
|
altLabel={baseDocumentLabel}
|
||||||
|
pageInputValue={pageInputValue}
|
||||||
|
onPageInputChange={handleTypingChange}
|
||||||
|
maxSharedPages={maxSharedPages}
|
||||||
|
/>
|
||||||
|
<CompareDocumentPane
|
||||||
|
pane="comparison"
|
||||||
|
layout={layout}
|
||||||
|
scrollRef={comparisonScrollRef}
|
||||||
|
peerScrollRef={baseScrollRef}
|
||||||
|
handleScrollSync={handleScrollSync}
|
||||||
|
handleWheelZoom={handleWheelZoom}
|
||||||
|
handleWheelOverscroll={handleWheelOverscroll}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
isPanMode={isPanMode}
|
||||||
|
zoom={comparisonZoom}
|
||||||
|
title={comparisonTitle}
|
||||||
|
dropdownPlaceholder={comparisonDropdownPlaceholder}
|
||||||
|
changes={mapChangesForDropdown(comparisonWordChanges)}
|
||||||
|
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)}
|
||||||
|
isLoading={isOperationLoading || comparisonLoading}
|
||||||
|
processingMessage={processingMessage}
|
||||||
|
pages={comparisonPages}
|
||||||
|
pairedPages={basePages}
|
||||||
|
getRowHeightPx={getRowHeightPx}
|
||||||
|
wordHighlightMap={wordHighlightMaps.comparison}
|
||||||
|
metaIndexToGroupId={metaIndexToGroupId.comparison}
|
||||||
|
documentLabel={comparisonDocumentLabel}
|
||||||
|
pageLabel={pageLabel}
|
||||||
|
altLabel={comparisonDocumentLabel}
|
||||||
|
pageInputValue={pageInputValue}
|
||||||
|
onPageInputChange={handleTypingChange}
|
||||||
|
maxSharedPages={maxSharedPages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CompareWorkbenchView;
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare';
|
||||||
|
import type { FileId } from '@app/types/file';
|
||||||
|
import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext';
|
||||||
|
import type { PagePreview } from '@app/types/compare';
|
||||||
|
|
||||||
|
/** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */
|
||||||
|
export const toRgba = (hexColor: string, alpha: number): string => {
|
||||||
|
const hex = hexColor.replace('#', '');
|
||||||
|
if (hex.length !== 6) return hexColor;
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16);
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16);
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalize rotation to [0, 360). */
|
||||||
|
export const normalizeRotation = (deg: number | undefined | null): number => {
|
||||||
|
const n = ((deg ?? 0) % 360 + 360) % 360;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge overlapping or touching rectangles into larger non-overlapping blocks.
|
||||||
|
* Robust across rotations (vertical groups) and prevents dark spots from overlaps.
|
||||||
|
*/
|
||||||
|
export const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
|
||||||
|
if (rects.length === 0) return rects;
|
||||||
|
const EPS = 0.004; // small tolerance in normalized page coords
|
||||||
|
const sorted = rects
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
|
||||||
|
const merged: TokenBoundingBox[] = [];
|
||||||
|
|
||||||
|
const overlapsOrTouches = (a: TokenBoundingBox, b: TokenBoundingBox) => {
|
||||||
|
const aR = a.left + a.width;
|
||||||
|
const aB = a.top + a.height;
|
||||||
|
const bR = b.left + b.width;
|
||||||
|
const bB = b.top + b.height;
|
||||||
|
return !(b.left > aR + EPS || bR < a.left - EPS || b.top > aB + EPS || bB < a.top - EPS);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const r of sorted) {
|
||||||
|
let mergedIntoExisting = false;
|
||||||
|
for (let i = 0; i < merged.length; i += 1) {
|
||||||
|
const m = merged[i];
|
||||||
|
if (overlapsOrTouches(m, r)) {
|
||||||
|
const left = Math.min(m.left, r.left);
|
||||||
|
const top = Math.min(m.top, r.top);
|
||||||
|
const right = Math.max(m.left + m.width, r.left + r.width);
|
||||||
|
const bottom = Math.max(m.top + m.height, r.top + r.height);
|
||||||
|
merged[i] = {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width: Math.max(0, right - left),
|
||||||
|
height: Math.max(0, bottom - top),
|
||||||
|
};
|
||||||
|
mergedIntoExisting = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mergedIntoExisting) merged.push({ ...r });
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Group word rectangles by change id using metaIndexToGroupId. */
|
||||||
|
export const groupWordRects = (
|
||||||
|
wordRects: WordHighlightEntry[],
|
||||||
|
metaIndexToGroupId: Map<number, string>,
|
||||||
|
pane: 'base' | 'comparison'
|
||||||
|
): Map<string, TokenBoundingBox[]> => {
|
||||||
|
const groupedRects = new Map<string, TokenBoundingBox[]>();
|
||||||
|
for (const { rect, metaIndex } of wordRects) {
|
||||||
|
const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`;
|
||||||
|
const current = groupedRects.get(id) ?? [];
|
||||||
|
current.push(rect);
|
||||||
|
groupedRects.set(id, current);
|
||||||
|
}
|
||||||
|
return groupedRects;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compute derived layout metrics for a page render, given environment and zoom. */
|
||||||
|
export const computePageLayoutMetrics = (args: {
|
||||||
|
page: PagePreview;
|
||||||
|
peerPage?: PagePreview | null;
|
||||||
|
layout: 'side-by-side' | 'stacked';
|
||||||
|
isMobileViewport: boolean;
|
||||||
|
scrollRefWidth: number | null;
|
||||||
|
viewportWidth: number;
|
||||||
|
zoom: number;
|
||||||
|
offsetPixels: number; // highlight offset in px relative to original page height
|
||||||
|
}) => {
|
||||||
|
const { page, peerPage, layout, isMobileViewport, scrollRefWidth, viewportWidth, zoom, offsetPixels } = args;
|
||||||
|
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
|
||||||
|
const fit = targetHeight / page.height;
|
||||||
|
const highlightOffset = offsetPixels / page.height;
|
||||||
|
const rotationNorm = normalizeRotation(page.rotation);
|
||||||
|
const isPortrait = rotationNorm === 0 || rotationNorm === 180;
|
||||||
|
const isStackedPortrait = layout === 'stacked' && isPortrait;
|
||||||
|
|
||||||
|
const containerW = scrollRefWidth ?? viewportWidth;
|
||||||
|
const stackedWidth = isMobileViewport
|
||||||
|
? Math.max(320, Math.round(containerW))
|
||||||
|
: Math.max(320, Math.round(viewportWidth * 0.5));
|
||||||
|
const stackedHeight = Math.round(stackedWidth * 1.4142);
|
||||||
|
|
||||||
|
const baseWidth = isStackedPortrait ? stackedWidth : Math.round(page.width * fit);
|
||||||
|
const baseHeight = isStackedPortrait ? stackedHeight : Math.round(targetHeight);
|
||||||
|
const containerMaxW = scrollRefWidth ?? viewportWidth;
|
||||||
|
|
||||||
|
// Container-first zooming with a stable baseline:
|
||||||
|
// Treat zoom=1 as "fit to available width" for the page's base size so
|
||||||
|
// the initial render is fully visible and centered (no cropping), regardless
|
||||||
|
// of rotation or pane/container width. When zoom < 1, shrink the container;
|
||||||
|
// when zoom > 1, keep the container at fit width and scale inner content.
|
||||||
|
const MIN_CONTAINER_WIDTH = 120;
|
||||||
|
const minScaleByWidth = MIN_CONTAINER_WIDTH / Math.max(1, baseWidth);
|
||||||
|
const fitScaleByContainer = containerMaxW / Math.max(1, baseWidth);
|
||||||
|
// Effective baseline scale used at zoom=1 (ensures at least the min width)
|
||||||
|
const baselineContainerScale = Math.max(minScaleByWidth, fitScaleByContainer);
|
||||||
|
// Lower bound the zoom so interactions remain stable
|
||||||
|
const desiredZoom = Math.max(0.1, zoom);
|
||||||
|
|
||||||
|
let containerScale: number;
|
||||||
|
let innerScale: number;
|
||||||
|
if (desiredZoom >= 1) {
|
||||||
|
// At or above baseline: keep container at fit width and scale inner content
|
||||||
|
containerScale = baselineContainerScale;
|
||||||
|
innerScale = +Math.max(0.1, desiredZoom).toFixed(4);
|
||||||
|
} else {
|
||||||
|
// Below baseline: shrink container proportionally, do not upscale inner
|
||||||
|
const scaled = baselineContainerScale * desiredZoom;
|
||||||
|
// Never smaller than minimum readable width
|
||||||
|
containerScale = Math.max(minScaleByWidth, scaled);
|
||||||
|
innerScale = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerWidth = Math.max(
|
||||||
|
MIN_CONTAINER_WIDTH,
|
||||||
|
Math.min(containerMaxW, Math.round(baseWidth * containerScale))
|
||||||
|
);
|
||||||
|
const containerHeight = Math.round(baseHeight * (containerWidth / Math.max(1, baseWidth)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetHeight,
|
||||||
|
fit,
|
||||||
|
highlightOffset,
|
||||||
|
rotationNorm,
|
||||||
|
isPortrait,
|
||||||
|
isStackedPortrait,
|
||||||
|
baseWidth,
|
||||||
|
baseHeight,
|
||||||
|
containerMaxW,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
innerScale,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Map changes to dropdown options tuple. */
|
||||||
|
export const mapChangesForDropdown = (
|
||||||
|
changes: Array<{ value: string; label: string; pageNumber: number }>
|
||||||
|
) => changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber }));
|
||||||
|
|
||||||
|
/** File selection helpers */
|
||||||
|
export const getFileFromSelection = (
|
||||||
|
explicit: StirlingFile | null | undefined,
|
||||||
|
fileId: FileId | null,
|
||||||
|
selectors: { getFile: (id: FileId) => StirlingFile | undefined | null }
|
||||||
|
): StirlingFile | null => {
|
||||||
|
if (explicit) return explicit;
|
||||||
|
if (!fileId) return null;
|
||||||
|
return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStubFromSelection = (
|
||||||
|
fileId: FileId | null,
|
||||||
|
selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined }
|
||||||
|
): StirlingFileStub | null => {
|
||||||
|
if (!fileId) return null;
|
||||||
|
const stub = selectors.getStirlingFileStub(fileId);
|
||||||
|
return stub ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Progress banner computations */
|
||||||
|
export const computeShowProgressBanner = (
|
||||||
|
totalsKnown: boolean,
|
||||||
|
baseTotal: number | null | undefined,
|
||||||
|
compTotal: number | null | undefined,
|
||||||
|
baseLoading: boolean,
|
||||||
|
compLoading: boolean,
|
||||||
|
threshold: number = 400
|
||||||
|
): boolean => {
|
||||||
|
if (!totalsKnown) return false;
|
||||||
|
const totals = [baseTotal ?? 0, compTotal ?? 0];
|
||||||
|
return Math.max(...totals) >= threshold && (baseLoading || compLoading);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeProgressPct = (
|
||||||
|
totalsKnown: boolean,
|
||||||
|
baseTotal: number | null | undefined,
|
||||||
|
compTotal: number | null | undefined,
|
||||||
|
baseRendered: number,
|
||||||
|
compRendered: number
|
||||||
|
): number => {
|
||||||
|
const totalCombined = totalsKnown ? ((baseTotal ?? 0) + (compTotal ?? 0)) : 0;
|
||||||
|
const renderedCombined = baseRendered + compRendered;
|
||||||
|
return totalsKnown && totalCombined > 0
|
||||||
|
? Math.min(100, Math.round((renderedCombined / totalCombined) * 100))
|
||||||
|
: 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeCountsText = (
|
||||||
|
baseRendered: number,
|
||||||
|
baseTotal: number | null | undefined,
|
||||||
|
baseLength: number,
|
||||||
|
compRendered: number,
|
||||||
|
compTotal: number | null | undefined,
|
||||||
|
compLength: number
|
||||||
|
): string => {
|
||||||
|
const baseTotalShown = baseTotal || baseLength;
|
||||||
|
const compTotalShown = compTotal || compLength;
|
||||||
|
return `${baseRendered}/${baseTotalShown} • ${compRendered}/${compTotalShown}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const computeMaxSharedPages = (
|
||||||
|
baseTotal: number | null | undefined,
|
||||||
|
compTotal: number | null | undefined,
|
||||||
|
baseLen: number,
|
||||||
|
compLen: number
|
||||||
|
): number => {
|
||||||
|
const baseMax = baseTotal || baseLen || 0;
|
||||||
|
const compMax = compTotal || compLen || 0;
|
||||||
|
const minKnown = Math.min(baseMax || Infinity, compMax || Infinity);
|
||||||
|
if (!Number.isFinite(minKnown)) return 0;
|
||||||
|
return Math.max(0, minKnown);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
.compare-dropdown-scrollwrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-dropdown-sticky {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--compare-page-label-bg);
|
||||||
|
color: var(--compare-page-label-fg);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .compare-dropdown-sticky {
|
||||||
|
background: var(--compare-page-label-bg);
|
||||||
|
color: var(--compare-page-label-fg);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.compare-workbench {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-workbench__content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pane__scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pane__content {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-workbench__mode {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-workbench__columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stacked mode: two rows with synchronized scroll panes */
|
||||||
|
.compare-workbench__columns--stacked {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-workbench__columns > div {
|
||||||
|
/* Critical for responsive flex children inside non-wrapping layouts */
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky header styling overrides */
|
||||||
|
.compare-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-toolbar);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: -0.5rem -0.5rem 0.5rem -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown badge-like style - only style the dropdowns, not titles */
|
||||||
|
.compare-changes-select {
|
||||||
|
background: var(--spdf-compare-removed-badge-bg) !important;
|
||||||
|
color: var(--spdf-compare-removed-badge-fg) !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select__placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select__placeholder .mantine-Loader-root {
|
||||||
|
display: inline-flex;
|
||||||
|
margin: 0 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison {
|
||||||
|
background: var(--spdf-compare-added-badge-bg) !important;
|
||||||
|
color: var(--spdf-compare-added-badge-fg) !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wider dropdown menu for long block text */
|
||||||
|
.compare-changes-dropdown {
|
||||||
|
min-width: 520px !important;
|
||||||
|
max-width: 70vw !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow: hidden; /* prevent inner elements from overflowing rounded edges */
|
||||||
|
border-radius: 8px !important; /* match dropdown container radius */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure options text uses full width inside wider dropdown */
|
||||||
|
.compare-dropdown-option__text {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default padding inside Mantine options list so sticky header touches edges */
|
||||||
|
.compare-changes-dropdown .mantine-Combobox-options {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-dropdown-scrollwrap { margin: 0; }
|
||||||
|
|
||||||
|
/* Ensure search field sits flush and does not overflow */
|
||||||
|
.compare-changes-dropdown .mantine-Combobox-search {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-top-left-radius: 8px !important;
|
||||||
|
border-top-right-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the dropdown container */
|
||||||
|
.compare-changes-select .mantine-Combobox-dropdown {
|
||||||
|
border: 1px solid var(--border-subtle) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: var(--shadow-md) !important;
|
||||||
|
background-color: var(--bg-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-Combobox-dropdown {
|
||||||
|
border: 1px solid var(--border-subtle) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-shadow: var(--shadow-md) !important;
|
||||||
|
background-color: var(--bg-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for ScrollArea */
|
||||||
|
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||||
|
width: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-muted) !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong) !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||||
|
width: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-muted) !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong) !important;
|
||||||
|
border-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the dropdown options */
|
||||||
|
.compare-changes-select .mantine-Combobox-option {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-Combobox-option {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select .mantine-Combobox-option:hover {
|
||||||
|
background-color: var(--spdf-compare-removed-badge-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-Combobox-option:hover {
|
||||||
|
background-color: var(--spdf-compare-added-badge-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the search input */
|
||||||
|
.compare-changes-select .mantine-Combobox-search {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
border-bottom: 1px solid var(--border-subtle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-Combobox-search {
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
border-bottom: 1px solid var(--border-subtle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select .mantine-Combobox-search::placeholder {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-changes-select--comparison .mantine-Combobox-search::placeholder {
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style the chevron - ensure proper coloring */
|
||||||
|
.compare-changes-select .mantine-Combobox-chevron,
|
||||||
|
.compare-changes-select--comparison .mantine-Combobox-chevron {
|
||||||
|
color: inherit !important;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flash/pulse highlight for navigated change */
|
||||||
|
@keyframes compare-flash {
|
||||||
|
0% {
|
||||||
|
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||||
|
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||||
|
background-color: rgba(255, 235, 59, 0.2) !important;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||||
|
box-shadow: 0 0 20px rgba(255, 235, 59, 0.8);
|
||||||
|
background-color: rgba(255, 235, 59, 0.4) !important;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||||
|
box-shadow: 0 0 30px rgba(255, 235, 59, 0.9);
|
||||||
|
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
outline: 4px solid rgba(255, 235, 59, 0.8);
|
||||||
|
box-shadow: 0 0 15px rgba(255, 235, 59, 0.6);
|
||||||
|
background-color: rgba(255, 235, 59, 0.3) !important;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||||
|
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||||
|
background-color: rgba(255, 235, 59, 0.0) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff-highlight--flash {
|
||||||
|
animation: compare-flash 1.5s ease-in-out 1;
|
||||||
|
z-index: 1000;
|
||||||
|
position: relative;
|
||||||
|
/* Bonus: temporarily override red/green to yellow during flash for clarity */
|
||||||
|
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Union overlay for group flash */
|
||||||
|
.compare-diff-flash-overlay {
|
||||||
|
animation: compare-flash 1.5s ease-in-out 1;
|
||||||
|
z-index: 999;
|
||||||
|
background-color: rgba(255, 235, 59, 0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-legend__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-legend__swatch {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-summary__stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-summary__stat-card {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-summary__segment {
|
||||||
|
border: 1px solid var(--mantine-color-gray-3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--mantine-color-gray-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff-page__canvas {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center canvas in stacked portrait mode (width/height set inline) */
|
||||||
|
.compare-diff-page__canvas[style*="margin-left: auto"] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff-page__canvas--zoom {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff-page__inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
background-color: #fff; /* ensure stable white backing during load */
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff-page__image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Centered per-page title wrapper */
|
||||||
|
.compare-page-title {
|
||||||
|
text-align: center;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-page-title .mantine-Text-root {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--compare-page-label-bg);
|
||||||
|
color: var(--compare-page-label-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay loader to avoid flash when image not yet loaded */
|
||||||
|
.compare-page-loader-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-diff-highlight {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compare dropdown option formatting (page + clamped text) */
|
||||||
|
.compare-dropdown-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
.compare-dropdown-option__page {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.compare-dropdown-option__text {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Non-sticky in-flow group headers; sticky handled by floating header */
|
||||||
|
.compare-dropdown-group {
|
||||||
|
position: static;
|
||||||
|
background: var(--compare-page-label-bg);
|
||||||
|
color: var(--compare-page-label-fg);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-dropdown-group.compare-dropdown-group--hidden {
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .compare-dropdown-group {
|
||||||
|
background: var(--compare-page-label-bg);
|
||||||
|
color: var(--compare-page-label-fg);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light grey rendering flag next to page labels in the dropdown */
|
||||||
|
.compare-dropdown-rendering-flag {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline paragraph highlights in summary */
|
||||||
|
.compare-inline {
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
padding: 0.05rem 0.15rem;
|
||||||
|
}
|
||||||
|
.compare-inline--removed {
|
||||||
|
background-color: var(--spdf-compare-inline-removed-bg);
|
||||||
|
}
|
||||||
|
.compare-inline--added {
|
||||||
|
background-color: var(--spdf-compare-inline-added-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pane-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--bg-background);
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */
|
||||||
|
.compare-tool__thumbnail {
|
||||||
|
width: 4rem;
|
||||||
|
height: 5.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-tool__details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: remove side margins and let canvases take full width inside column */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.compare-workbench__columns {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.compare-diff-page__canvas.compare-diff-page__canvas--zoom {
|
||||||
|
width: 100% !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
.compare-diff-page__inner {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { RefObject, useCallback } from 'react';
|
||||||
|
|
||||||
|
type Pane = 'base' | 'comparison';
|
||||||
|
|
||||||
|
type SuppressOptions = {
|
||||||
|
temporarilySuppressScrollLink?: (fn: () => void, durationMs?: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCompareChangeNavigation = (
|
||||||
|
baseScrollRef: RefObject<HTMLDivElement | null>,
|
||||||
|
comparisonScrollRef: RefObject<HTMLDivElement | null>,
|
||||||
|
options?: SuppressOptions,
|
||||||
|
) => {
|
||||||
|
return useCallback(
|
||||||
|
(changeValue: string, pane: Pane, pageNumber?: number) => {
|
||||||
|
const suppress = <T extends void>(fn: () => T) => {
|
||||||
|
if (options?.temporarilySuppressScrollLink) {
|
||||||
|
options.temporarilySuppressScrollLink(fn, 700);
|
||||||
|
} else {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef;
|
||||||
|
const container = targetRef.current;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const findNodes = (): HTMLElement[] => {
|
||||||
|
return Array.from(
|
||||||
|
container.querySelectorAll(`[data-change-id="${changeValue}"]`)
|
||||||
|
) as HTMLElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToPageIfNeeded = () => {
|
||||||
|
if (!pageNumber) return false;
|
||||||
|
const pageEl = container.querySelector(
|
||||||
|
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!pageEl) return false;
|
||||||
|
const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2);
|
||||||
|
suppress(() => {
|
||||||
|
container.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollPeerPageIfPossible = () => {
|
||||||
|
if (!pageNumber) return;
|
||||||
|
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||||
|
const peer = peerRef.current;
|
||||||
|
if (!peer) return;
|
||||||
|
const peerPageEl = peer.querySelector(
|
||||||
|
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!peerPageEl) return;
|
||||||
|
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||||
|
const top = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
peerMaxTop,
|
||||||
|
peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
suppress(() => {
|
||||||
|
peer.scrollTo({ top, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const proceedWithNodes = (nodes: HTMLElement[]) => {
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
|
||||||
|
// Prefer a percent-in-page based vertical scroll, which is resilient to transforms.
|
||||||
|
const anchor = nodes[0];
|
||||||
|
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||||
|
const inner = anchor.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||||
|
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||||
|
if (pageEl && inner && !Number.isNaN(topPercent)) {
|
||||||
|
const innerRect = inner.getBoundingClientRect();
|
||||||
|
const innerHeight = Math.max(1, innerRect.height);
|
||||||
|
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||||
|
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
|
const desiredTop = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(maxTop, pageEl.offsetTop + absoluteTopInPage - container.clientHeight / 2)
|
||||||
|
);
|
||||||
|
suppress(() => {
|
||||||
|
container.scrollTo({ top: desiredTop, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to bounding-rect based centering if percent approach is unavailable.
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
let minTop = Number.POSITIVE_INFINITY;
|
||||||
|
let minLeft = Number.POSITIVE_INFINITY;
|
||||||
|
let maxBottom = Number.NEGATIVE_INFINITY;
|
||||||
|
let maxRight = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
nodes.forEach((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
minTop = Math.min(minTop, rect.top);
|
||||||
|
minLeft = Math.min(minLeft, rect.left);
|
||||||
|
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||||
|
maxRight = Math.max(maxRight, rect.right);
|
||||||
|
});
|
||||||
|
|
||||||
|
const boxHeight = Math.max(1, maxBottom - minTop);
|
||||||
|
const boxWidth = Math.max(1, maxRight - minLeft);
|
||||||
|
const absoluteTop = minTop - containerRect.top + container.scrollTop;
|
||||||
|
const absoluteLeft = minLeft - containerRect.left + container.scrollLeft;
|
||||||
|
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||||
|
const desiredTop = Math.max(0, Math.min(maxTop, absoluteTop - (container.clientHeight - boxHeight) / 2));
|
||||||
|
const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2);
|
||||||
|
|
||||||
|
suppress(() => {
|
||||||
|
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also scroll the peer container to the corresponding location in the
|
||||||
|
// other PDF (same page and approximate vertical position within page),
|
||||||
|
// not just the same list/scroll position.
|
||||||
|
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||||
|
const peer = peerRef.current;
|
||||||
|
if (peer) {
|
||||||
|
// Use the first node as the anchor
|
||||||
|
const anchor = nodes[0];
|
||||||
|
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||||
|
const pageNumAttr = pageEl?.getAttribute('data-page-number');
|
||||||
|
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||||
|
if (pageNumAttr) {
|
||||||
|
const peerPageEl = peer.querySelector(
|
||||||
|
`.compare-diff-page[data-page-number="${pageNumAttr}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
const peerInner = peerPageEl?.querySelector('.compare-diff-page__inner') as HTMLElement | null;
|
||||||
|
if (peerPageEl && peerInner) {
|
||||||
|
const innerRect = peerInner.getBoundingClientRect();
|
||||||
|
const innerHeight = Math.max(1, innerRect.height);
|
||||||
|
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||||
|
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||||
|
const peerDesiredTop = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2)
|
||||||
|
);
|
||||||
|
suppress(() => {
|
||||||
|
peer.scrollTo({ top: peerDesiredTop, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
} else if (peerPageEl) {
|
||||||
|
// Fallback: Scroll to page top (clamped)
|
||||||
|
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||||
|
const top = Math.max(0, Math.min(peerMaxTop, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)));
|
||||||
|
suppress(() => {
|
||||||
|
peer.scrollTo({ top, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsByInner = new Map<HTMLElement, HTMLElement[]>();
|
||||||
|
nodes.forEach((element) => {
|
||||||
|
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||||
|
if (!inner) return;
|
||||||
|
const list = groupsByInner.get(inner) ?? [];
|
||||||
|
list.push(element);
|
||||||
|
groupsByInner.set(inner, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupsByInner.forEach((elements, inner) => {
|
||||||
|
let minL = 100;
|
||||||
|
let minT = 100;
|
||||||
|
let maxR = 0;
|
||||||
|
let maxB = 0;
|
||||||
|
elements.forEach((element) => {
|
||||||
|
const leftPercent = parseFloat(element.style.left) || 0;
|
||||||
|
const topPercent = parseFloat(element.style.top) || 0;
|
||||||
|
const widthPercent = parseFloat(element.style.width) || 0;
|
||||||
|
const heightPercent = parseFloat(element.style.height) || 0;
|
||||||
|
minL = Math.min(minL, leftPercent);
|
||||||
|
minT = Math.min(minT, topPercent);
|
||||||
|
maxR = Math.max(maxR, leftPercent + widthPercent);
|
||||||
|
maxB = Math.max(maxB, topPercent + heightPercent);
|
||||||
|
});
|
||||||
|
const overlay = document.createElement('span');
|
||||||
|
overlay.className = 'compare-diff-flash-overlay';
|
||||||
|
overlay.style.position = 'absolute';
|
||||||
|
overlay.style.left = `${minL}%`;
|
||||||
|
overlay.style.top = `${minT}%`;
|
||||||
|
overlay.style.width = `${Math.max(0.1, maxR - minL)}%`;
|
||||||
|
overlay.style.height = `${Math.max(0.1, maxB - minT)}%`;
|
||||||
|
inner.appendChild(overlay);
|
||||||
|
window.setTimeout(() => overlay.remove(), 1600);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach((element) => {
|
||||||
|
element.classList.remove('compare-diff-highlight--flash');
|
||||||
|
});
|
||||||
|
void container.clientWidth; // Force reflow
|
||||||
|
nodes.forEach((element) => {
|
||||||
|
element.classList.add('compare-diff-highlight--flash');
|
||||||
|
window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodes = findNodes();
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
proceedWithNodes(nodes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page-level fallback immediately so the user sees something happen
|
||||||
|
const scrolledPage = scrollToPageIfNeeded();
|
||||||
|
if (scrolledPage) {
|
||||||
|
scrollPeerPageIfPossible();
|
||||||
|
} else {
|
||||||
|
// Even if the page element is not present yet, try to nudge peer pane
|
||||||
|
scrollPeerPageIfPossible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for highlights to mount (pages/images render progressively)
|
||||||
|
let settled = false;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (settled) return;
|
||||||
|
const n = findNodes();
|
||||||
|
if (n.length > 0) {
|
||||||
|
settled = true;
|
||||||
|
observer.disconnect();
|
||||||
|
proceedWithNodes(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
observer.observe(container, { childList: true, subtree: true });
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
// Safety timeout to stop waiting after a while
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
observer.disconnect();
|
||||||
|
// We already scrolled to the page above; nothing else to do.
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
[baseScrollRef, comparisonScrollRef]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseCompareChangeNavigationReturn = ReturnType<typeof useCompareChangeNavigation>;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import type {
|
||||||
|
CompareFilteredTokenInfo,
|
||||||
|
WordHighlightEntry,
|
||||||
|
CompareResultData,
|
||||||
|
CompareChangeOption,
|
||||||
|
PagePreview,
|
||||||
|
} from '@app/types/compare';
|
||||||
|
|
||||||
|
interface MetaGroupMap {
|
||||||
|
base: Map<number, string>;
|
||||||
|
comparison: Map<number, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordHighlightMaps {
|
||||||
|
base: Map<number, WordHighlightEntry[]>;
|
||||||
|
comparison: Map<number, WordHighlightEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCompareHighlightsResult {
|
||||||
|
baseWordChanges: CompareChangeOption[];
|
||||||
|
comparisonWordChanges: CompareChangeOption[];
|
||||||
|
metaIndexToGroupId: MetaGroupMap;
|
||||||
|
wordHighlightMaps: WordHighlightMaps;
|
||||||
|
getRowHeightPx: (pageNumber: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildWordChanges = (
|
||||||
|
tokens: CompareFilteredTokenInfo[],
|
||||||
|
metaIndexToGroupId: Map<number, string>,
|
||||||
|
groupPrefix: string
|
||||||
|
): CompareChangeOption[] => {
|
||||||
|
metaIndexToGroupId.clear();
|
||||||
|
if (!tokens.length) return [];
|
||||||
|
|
||||||
|
const items: CompareChangeOption[] = [];
|
||||||
|
let currentRun: CompareFilteredTokenInfo[] = [];
|
||||||
|
|
||||||
|
const flushRun = () => {
|
||||||
|
if (currentRun.length === 0) return;
|
||||||
|
const label = currentRun.map((token) => token.token).join(' ').trim();
|
||||||
|
if (label.length === 0) {
|
||||||
|
currentRun = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = currentRun[0];
|
||||||
|
const last = currentRun[currentRun.length - 1];
|
||||||
|
const groupId = `${groupPrefix}-t${first.metaIndex}-t${last.metaIndex}`;
|
||||||
|
currentRun.forEach((token) => {
|
||||||
|
metaIndexToGroupId.set(token.metaIndex, groupId);
|
||||||
|
});
|
||||||
|
const pageNumber = first.page ?? last.page ?? 1;
|
||||||
|
items.push({ value: groupId, label, pageNumber });
|
||||||
|
currentRun = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.hasHighlight && token.bbox) {
|
||||||
|
currentRun.push(token);
|
||||||
|
} else {
|
||||||
|
flushRun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushRun();
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildHighlightMap = (
|
||||||
|
tokens: CompareFilteredTokenInfo[]
|
||||||
|
): Map<number, WordHighlightEntry[]> => {
|
||||||
|
const map = new Map<number, WordHighlightEntry[]>();
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.hasHighlight || !token.bbox || token.page == null) continue;
|
||||||
|
const list = map.get(token.page) ?? [];
|
||||||
|
list.push({ rect: token.bbox, metaIndex: token.metaIndex });
|
||||||
|
map.set(token.page, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCompareHighlights = (
|
||||||
|
result: CompareResultData | null,
|
||||||
|
basePages: PagePreview[],
|
||||||
|
comparisonPages: PagePreview[],
|
||||||
|
): UseCompareHighlightsResult => {
|
||||||
|
const baseMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||||
|
const comparisonMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||||
|
|
||||||
|
const baseWordChanges = useMemo(() => {
|
||||||
|
if (!result) return [];
|
||||||
|
return buildWordChanges(
|
||||||
|
result.filteredTokenData.base,
|
||||||
|
baseMetaIndexToGroupId,
|
||||||
|
'base-group'
|
||||||
|
);
|
||||||
|
}, [baseMetaIndexToGroupId, result]);
|
||||||
|
|
||||||
|
const comparisonWordChanges = useMemo(() => {
|
||||||
|
if (!result) return [];
|
||||||
|
return buildWordChanges(
|
||||||
|
result.filteredTokenData.comparison,
|
||||||
|
comparisonMetaIndexToGroupId,
|
||||||
|
'comparison-group'
|
||||||
|
);
|
||||||
|
}, [comparisonMetaIndexToGroupId, result]);
|
||||||
|
|
||||||
|
const wordHighlightMaps = useMemo(() => {
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
base: new Map<number, WordHighlightEntry[]>(),
|
||||||
|
comparison: new Map<number, WordHighlightEntry[]>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: buildHighlightMap(result.filteredTokenData.base),
|
||||||
|
comparison: buildHighlightMap(result.filteredTokenData.comparison),
|
||||||
|
};
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const getRowHeightPx = useCallback(
|
||||||
|
(pageNumber: number) => {
|
||||||
|
const basePage = basePages.find((page) => page.pageNumber === pageNumber);
|
||||||
|
const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber);
|
||||||
|
const baseHeight = basePage ? basePage.height : 0;
|
||||||
|
const comparisonHeight = comparisonPage ? comparisonPage.height : 0;
|
||||||
|
const rowHeight = Math.max(baseHeight, comparisonHeight);
|
||||||
|
return Math.round(rowHeight);
|
||||||
|
},
|
||||||
|
[basePages, comparisonPages]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseWordChanges,
|
||||||
|
comparisonWordChanges,
|
||||||
|
metaIndexToGroupId: {
|
||||||
|
base: baseMetaIndexToGroupId,
|
||||||
|
comparison: comparisonMetaIndexToGroupId,
|
||||||
|
},
|
||||||
|
wordHighlightMaps,
|
||||||
|
getRowHeightPx,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||||
|
import type { PagePreview } from '@app/types/compare';
|
||||||
|
|
||||||
|
const DISPLAY_SCALE = 1;
|
||||||
|
|
||||||
|
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||||
|
|
||||||
|
// Observable preview cache so rendering progress can resume across remounts and view switches
|
||||||
|
type CacheEntry = { pages: PagePreview[]; total: number; subscribers: Set<() => void> };
|
||||||
|
const previewCache: Map<string, CacheEntry> = new Map();
|
||||||
|
const latestVersionMap: Map<string, symbol> = new Map();
|
||||||
|
|
||||||
|
const getOrCreateEntry = (key: string): CacheEntry => {
|
||||||
|
let entry = previewCache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { pages: [], total: 0, subscribers: new Set() };
|
||||||
|
previewCache.set(key, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const notify = (entry: CacheEntry) => {
|
||||||
|
entry.subscribers.forEach((fn) => {
|
||||||
|
try { fn(); } catch { /* no-op */ }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribe = (key: string, fn: () => void): (() => void) => {
|
||||||
|
const entry = getOrCreateEntry(key);
|
||||||
|
entry.subscribers.add(fn);
|
||||||
|
return () => entry.subscribers.delete(fn);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendBatchToCache = (key: string, batch: PagePreview[], provisionalTotal?: number) => {
|
||||||
|
const entry = getOrCreateEntry(key);
|
||||||
|
const next = entry.pages.slice();
|
||||||
|
for (const p of batch) {
|
||||||
|
const idx = next.findIndex((x) => x.pageNumber > p.pageNumber);
|
||||||
|
if (idx === -1) next.push(p); else next.splice(idx, 0, p);
|
||||||
|
}
|
||||||
|
entry.pages = next;
|
||||||
|
if (typeof provisionalTotal === 'number' && entry.total === 0) entry.total = provisionalTotal;
|
||||||
|
notify(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTotalInCache = (key: string, total: number) => {
|
||||||
|
const entry = getOrCreateEntry(key);
|
||||||
|
entry.total = total;
|
||||||
|
notify(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const replacePagesInCache = (key: string, pages: PagePreview[], total?: number) => {
|
||||||
|
const entry = getOrCreateEntry(key);
|
||||||
|
entry.pages = pages.slice();
|
||||||
|
if (typeof total === 'number') entry.total = total;
|
||||||
|
notify(entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const renderPdfDocumentToImages = async (
|
||||||
|
file: File,
|
||||||
|
onBatch?: (previews: PagePreview[]) => void,
|
||||||
|
batchSize: number = 12,
|
||||||
|
onInitTotal?: (totalPages: number) => void,
|
||||||
|
startAtPage: number = 1,
|
||||||
|
shouldAbort?: () => boolean,
|
||||||
|
): Promise<PagePreview[]> => {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||||
|
disableAutoFetch: true,
|
||||||
|
disableStream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const previews: PagePreview[] = [];
|
||||||
|
const dpr = getDevicePixelRatio();
|
||||||
|
const renderScale = Math.max(2, Math.min(3, dpr * 2));
|
||||||
|
onInitTotal?.(pdf.numPages);
|
||||||
|
|
||||||
|
let batch: PagePreview[] = [];
|
||||||
|
const shouldStop = () => Boolean(shouldAbort?.());
|
||||||
|
|
||||||
|
for (let pageNumber = Math.max(1, startAtPage); pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
|
if (shouldStop()) break;
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
|
||||||
|
const renderViewport = page.getViewport({ scale: renderScale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
canvas.width = Math.round(renderViewport.width);
|
||||||
|
canvas.height = Math.round(renderViewport.height);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
page.cleanup();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||||
|
if (shouldStop()) break;
|
||||||
|
|
||||||
|
const preview: PagePreview = {
|
||||||
|
pageNumber,
|
||||||
|
width: Math.round(displayViewport.width),
|
||||||
|
height: Math.round(displayViewport.height),
|
||||||
|
rotation: (page.rotate || 0) % 360,
|
||||||
|
url: canvas.toDataURL(),
|
||||||
|
};
|
||||||
|
previews.push(preview);
|
||||||
|
if (onBatch) {
|
||||||
|
batch.push(preview);
|
||||||
|
if (batch.length >= batchSize) {
|
||||||
|
onBatch(batch);
|
||||||
|
batch = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
page.cleanup();
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldStop()) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onBatch && batch.length > 0) onBatch(batch);
|
||||||
|
return previews;
|
||||||
|
} finally {
|
||||||
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseComparePagePreviewsOptions {
|
||||||
|
file: File | null;
|
||||||
|
enabled: boolean;
|
||||||
|
cacheKey: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useComparePagePreviews = ({
|
||||||
|
file,
|
||||||
|
enabled,
|
||||||
|
cacheKey,
|
||||||
|
}: UseComparePagePreviewsOptions) => {
|
||||||
|
const [pages, setPages] = useState<PagePreview[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const inFlightRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (!file || !enabled) {
|
||||||
|
setPages([]);
|
||||||
|
setLoading(false);
|
||||||
|
setTotalPages(0);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`;
|
||||||
|
const refreshVersion = Symbol(key);
|
||||||
|
latestVersionMap.set(key, refreshVersion);
|
||||||
|
const entry = getOrCreateEntry(key);
|
||||||
|
const cachedTotal = entry.total ?? (entry.pages.length ?? 0);
|
||||||
|
let lastKnownTotal = cachedTotal;
|
||||||
|
const isFullyCached = Boolean(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal);
|
||||||
|
|
||||||
|
if (entry.pages.length > 0) {
|
||||||
|
const nextPages = entry.pages.slice();
|
||||||
|
setPages(nextPages);
|
||||||
|
setTotalPages(cachedTotal);
|
||||||
|
} else {
|
||||||
|
setTotalPages(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(!isFullyCached);
|
||||||
|
|
||||||
|
const unsubscribe = subscribe(key, () => {
|
||||||
|
const e = getOrCreateEntry(key);
|
||||||
|
setPages(e.pages.slice());
|
||||||
|
setTotalPages(e.total);
|
||||||
|
const done = e.pages.length > 0 && e.total > 0 && e.pages.length >= e.total;
|
||||||
|
setLoading(!done);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isFullyCached) {
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
inFlightRef.current += 1;
|
||||||
|
const current = inFlightRef.current;
|
||||||
|
const startAt = (entry?.pages?.length ?? 0) + 1;
|
||||||
|
const previews = await renderPdfDocumentToImages(
|
||||||
|
file,
|
||||||
|
(batch) => {
|
||||||
|
if (cancelled || current !== inFlightRef.current) return;
|
||||||
|
appendBatchToCache(key, batch, lastKnownTotal || cachedTotal);
|
||||||
|
},
|
||||||
|
16,
|
||||||
|
(total) => {
|
||||||
|
if (!cancelled && current === inFlightRef.current) {
|
||||||
|
lastKnownTotal = total;
|
||||||
|
setTotalInCache(key, total);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startAt,
|
||||||
|
() => cancelled || current !== inFlightRef.current
|
||||||
|
);
|
||||||
|
if (!cancelled && current === inFlightRef.current) {
|
||||||
|
const stillLatest = latestVersionMap.get(key) === refreshVersion;
|
||||||
|
if (!stillLatest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cacheEntry = getOrCreateEntry(key);
|
||||||
|
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length;
|
||||||
|
lastKnownTotal = finalTotal;
|
||||||
|
const cachePages = cacheEntry.pages ?? [];
|
||||||
|
const preferPreviews = previews.length > cachePages.length;
|
||||||
|
const finalPages = preferPreviews ? previews.slice() : cachePages.slice();
|
||||||
|
replacePagesInCache(key, finalPages, finalTotal);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[compare] failed to render document preview', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setPages([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [file, enabled, cacheKey]);
|
||||||
|
|
||||||
|
return { pages, loading, totalPages, renderedPages: pages.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseComparePagePreviewsReturn = ReturnType<typeof useComparePagePreviews>;
|
||||||
@@ -0,0 +1,895 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import type {
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
TouchEvent as ReactTouchEvent,
|
||||||
|
WheelEvent as ReactWheelEvent,
|
||||||
|
} from 'react';
|
||||||
|
import type {
|
||||||
|
PagePreview,
|
||||||
|
ComparePane as Pane,
|
||||||
|
PanState,
|
||||||
|
ScrollLinkDelta,
|
||||||
|
ScrollLinkAnchors,
|
||||||
|
PanDragState,
|
||||||
|
PinchState,
|
||||||
|
UseComparePanZoomOptions,
|
||||||
|
UseComparePanZoomReturn,
|
||||||
|
} from '@app/types/compare';
|
||||||
|
|
||||||
|
const ZOOM_MIN = 0.5;
|
||||||
|
const ZOOM_MAX = 100000;
|
||||||
|
const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
// Default structural adjustments applied to each rendered page row. These are
|
||||||
|
// refined at runtime via DOM measurements once the panes have mounted.
|
||||||
|
const DEFAULT_ROW_STRUCTURAL_EXTRA = 32;
|
||||||
|
const DEFAULT_ROW_GAP = 8;
|
||||||
|
|
||||||
|
// (Interfaces moved to @app/types/compare)
|
||||||
|
|
||||||
|
export const useComparePanZoom = ({
|
||||||
|
basePages,
|
||||||
|
comparisonPages,
|
||||||
|
prefersStacked,
|
||||||
|
}: UseComparePanZoomOptions): UseComparePanZoomReturn => {
|
||||||
|
const baseScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const comparisonScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isSyncingRef = useRef(false);
|
||||||
|
const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false });
|
||||||
|
const scrollLinkDeltaRef = useRef<ScrollLinkDelta>({ vertical: 0, horizontal: 0 });
|
||||||
|
const scrollLinkAnchorsRef = useRef<ScrollLinkAnchors>({
|
||||||
|
deltaPixelsBaseToComp: 0,
|
||||||
|
deltaPixelsCompToBase: 0,
|
||||||
|
});
|
||||||
|
const [isScrollLinked, setIsScrollLinked] = useState(true);
|
||||||
|
const [isPanMode, setIsPanMode] = useState(false);
|
||||||
|
const panDragRef = useRef<PanDragState>({
|
||||||
|
active: false,
|
||||||
|
source: null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
startPanX: 0,
|
||||||
|
startPanY: 0,
|
||||||
|
targetStartPanX: 0,
|
||||||
|
targetStartPanY: 0,
|
||||||
|
});
|
||||||
|
const lastActivePaneRef = useRef<Pane>('base');
|
||||||
|
const [baseZoom, setBaseZoom] = useState(1);
|
||||||
|
const [comparisonZoom, setComparisonZoom] = useState(1);
|
||||||
|
const [basePan, setBasePan] = useState<PanState>({ x: 0, y: 0 });
|
||||||
|
const [comparisonPan, setComparisonPan] = useState<PanState>({ x: 0, y: 0 });
|
||||||
|
const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||||
|
const pinchRef = useRef<PinchState>({ active: false, pane: null, startDistance: 0, startZoom: 1 });
|
||||||
|
const edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||||
|
const [rowStructuralExtraPx, setRowStructuralExtraPx] = useState(DEFAULT_ROW_STRUCTURAL_EXTRA);
|
||||||
|
const [rowGapPx, setRowGapPx] = useState(DEFAULT_ROW_GAP);
|
||||||
|
|
||||||
|
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
|
||||||
|
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
|
||||||
|
setLayoutState(next);
|
||||||
|
}, []);
|
||||||
|
const toggleLayout = useCallback(() => {
|
||||||
|
setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev));
|
||||||
|
}, [prefersStacked]);
|
||||||
|
|
||||||
|
const getPagesForPane = useCallback(
|
||||||
|
(pane: Pane) => (pane === 'base' ? basePages : comparisonPages),
|
||||||
|
[basePages, comparisonPages]
|
||||||
|
);
|
||||||
|
|
||||||
|
// rAF-coalesced follower scroll writes
|
||||||
|
const syncRafRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||||
|
const desiredTopRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||||
|
|
||||||
|
const canonicalLayout = useMemo(() => {
|
||||||
|
const baseMap = new Map<number, PagePreview>();
|
||||||
|
const compMap = new Map<number, PagePreview>();
|
||||||
|
for (const page of basePages) baseMap.set(page.pageNumber, page);
|
||||||
|
for (const page of comparisonPages) compMap.set(page.pageNumber, page);
|
||||||
|
|
||||||
|
const allPageNumbers = Array.from(
|
||||||
|
new Set([
|
||||||
|
...basePages.map(p => p.pageNumber),
|
||||||
|
...comparisonPages.map(p => p.pageNumber),
|
||||||
|
])
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const rows = allPageNumbers.map(pageNumber => {
|
||||||
|
const basePage = baseMap.get(pageNumber) ?? null;
|
||||||
|
const compPage = compMap.get(pageNumber) ?? null;
|
||||||
|
const canonicalHeight = Math.max(basePage?.height ?? 0, compPage?.height ?? 0);
|
||||||
|
return {
|
||||||
|
pageNumber,
|
||||||
|
canonicalHeight,
|
||||||
|
hasBase: Boolean(basePage),
|
||||||
|
hasComparison: Boolean(compPage),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCanonicalHeight = rows.reduce((sum, row) => sum + Math.round(row.canonicalHeight), 0);
|
||||||
|
return { rows, totalCanonicalHeight };
|
||||||
|
}, [basePages, comparisonPages]);
|
||||||
|
|
||||||
|
// Measure structural padding (labels, internal gaps) and the inter-row gap
|
||||||
|
// so the scroll mapper can account for real DOM layout instead of relying on
|
||||||
|
// bare page image heights.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (canonicalLayout.rows.length === 0) return;
|
||||||
|
|
||||||
|
const raf = window.requestAnimationFrame(() => {
|
||||||
|
const sourceContent =
|
||||||
|
baseScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content') ??
|
||||||
|
comparisonScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content');
|
||||||
|
if (!sourceContent) return;
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(sourceContent);
|
||||||
|
const gapStr = style.rowGap || style.gap;
|
||||||
|
const parsedGap = gapStr ? Number.parseFloat(gapStr) : Number.NaN;
|
||||||
|
const measuredGap = Number.isNaN(parsedGap) ? rowGapPx : Math.max(0, Math.round(parsedGap));
|
||||||
|
if (measuredGap !== rowGapPx) {
|
||||||
|
setRowGapPx(measuredGap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalGap = Math.max(0, canonicalLayout.rows.length - 1) * measuredGap;
|
||||||
|
const contentHeight = Math.round(sourceContent.scrollHeight);
|
||||||
|
const available = contentHeight - totalGap - canonicalLayout.totalCanonicalHeight;
|
||||||
|
const candidate = canonicalLayout.rows.length > 0
|
||||||
|
? Math.max(0, Math.round(available / canonicalLayout.rows.length))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (Math.abs(candidate - rowStructuralExtraPx) >= 1) {
|
||||||
|
setRowStructuralExtraPx(candidate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(raf);
|
||||||
|
}, [canonicalLayout, rowGapPx, rowStructuralExtraPx, layout]);
|
||||||
|
|
||||||
|
// Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height
|
||||||
|
const rowHeights = useMemo(() => {
|
||||||
|
const totalRows = canonicalLayout.rows.length;
|
||||||
|
const base: number[] = [];
|
||||||
|
const comp: number[] = [];
|
||||||
|
for (let index = 0; index < totalRows; index += 1) {
|
||||||
|
const row = canonicalLayout.rows[index];
|
||||||
|
const canonicalHeight = Math.round(row.canonicalHeight);
|
||||||
|
const structuralHeight = Math.max(0, Math.round(canonicalHeight + rowStructuralExtraPx));
|
||||||
|
const includeGap = index < totalRows - 1 ? rowGapPx : 0;
|
||||||
|
const totalHeight = structuralHeight + includeGap;
|
||||||
|
if (row.hasBase) base.push(totalHeight);
|
||||||
|
else if (row.hasComparison) base.push(totalHeight);
|
||||||
|
if (row.hasComparison) comp.push(totalHeight);
|
||||||
|
else if (row.hasBase) comp.push(totalHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = (arr: number[]) => {
|
||||||
|
const out: number[] = new Array(arr.length + 1);
|
||||||
|
out[0] = 0;
|
||||||
|
for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i];
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
base,
|
||||||
|
comp,
|
||||||
|
basePrefix: prefix(base),
|
||||||
|
compPrefix: prefix(comp),
|
||||||
|
};
|
||||||
|
}, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]);
|
||||||
|
|
||||||
|
const mapScrollTopBetweenPanes = useCallback(
|
||||||
|
(sourceTop: number, sourceIsBase: boolean): number => {
|
||||||
|
const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp;
|
||||||
|
const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base;
|
||||||
|
const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix;
|
||||||
|
const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix;
|
||||||
|
|
||||||
|
if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop;
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1);
|
||||||
|
const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop)));
|
||||||
|
|
||||||
|
// Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1]
|
||||||
|
let lo = 0;
|
||||||
|
let hi = srcHeights.length - 1;
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = Math.floor((lo + hi + 1) / 2);
|
||||||
|
if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1;
|
||||||
|
}
|
||||||
|
const i = lo;
|
||||||
|
const within = top - srcPrefix[i];
|
||||||
|
const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0;
|
||||||
|
|
||||||
|
const j = Math.min(i, dstHeights.length - 1);
|
||||||
|
const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1);
|
||||||
|
return dstTop;
|
||||||
|
},
|
||||||
|
[rowHeights]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMaxCanvasSize = useCallback(
|
||||||
|
(pane: Pane) => {
|
||||||
|
const pages = getPagesForPane(pane);
|
||||||
|
const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base');
|
||||||
|
let maxW = 0;
|
||||||
|
let maxH = 0;
|
||||||
|
for (const page of pages) {
|
||||||
|
const peer = peers.find(p => p.pageNumber === page.pageNumber);
|
||||||
|
const targetHeight = peer ? Math.max(page.height, peer.height) : page.height;
|
||||||
|
const fit = targetHeight / page.height;
|
||||||
|
const width = Math.round(page.width * fit);
|
||||||
|
const height = Math.round(targetHeight);
|
||||||
|
if (width > maxW) maxW = width;
|
||||||
|
if (height > maxH) maxH = height;
|
||||||
|
}
|
||||||
|
return { maxW, maxH };
|
||||||
|
},
|
||||||
|
[getPagesForPane]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPanBounds = useCallback(
|
||||||
|
(pane: Pane, zoomOverride?: number) => {
|
||||||
|
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||||
|
const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null;
|
||||||
|
let canvasW: number | null = null;
|
||||||
|
let canvasH: number | null = null;
|
||||||
|
if (canvasEl) {
|
||||||
|
const rect = canvasEl.getBoundingClientRect();
|
||||||
|
canvasW = Math.max(0, Math.round(rect.width));
|
||||||
|
canvasH = Math.max(0, Math.round(rect.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = getMaxCanvasSize(pane);
|
||||||
|
const W = canvasW ?? fallback.maxW;
|
||||||
|
const H = canvasH ?? fallback.maxH;
|
||||||
|
const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom;
|
||||||
|
const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1));
|
||||||
|
const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1));
|
||||||
|
return { maxX: extraX, maxY: extraY };
|
||||||
|
},
|
||||||
|
[baseZoom, comparisonZoom, getMaxCanvasSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPaneRotation = useCallback(
|
||||||
|
(pane: Pane) => {
|
||||||
|
const pages = getPagesForPane(pane);
|
||||||
|
const rotation = pages[0]?.rotation ?? 0;
|
||||||
|
const normalized = ((rotation % 360) + 360) % 360;
|
||||||
|
return normalized as 0 | 90 | 180 | 270 | number;
|
||||||
|
},
|
||||||
|
[getPagesForPane]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapPanBetweenOrientations = useCallback(
|
||||||
|
(source: Pane, target: Pane, sourcePan: PanState) => {
|
||||||
|
const sourceRotation = getPaneRotation(source);
|
||||||
|
const targetRotation = getPaneRotation(target);
|
||||||
|
const sourceBounds = getPanBounds(source);
|
||||||
|
const targetBounds = getPanBounds(target);
|
||||||
|
|
||||||
|
const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1;
|
||||||
|
const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1;
|
||||||
|
|
||||||
|
const applyRotation = (nx: number, ny: number, rotation: number) => {
|
||||||
|
const r = ((rotation % 360) + 360) % 360;
|
||||||
|
if (r === 0) return { nx, ny };
|
||||||
|
if (r === 90) return { nx: ny, ny: -nx };
|
||||||
|
if (r === 180) return { nx: -nx, ny: -ny };
|
||||||
|
if (r === 270) return { nx: -ny, ny: nx };
|
||||||
|
return { nx, ny };
|
||||||
|
};
|
||||||
|
|
||||||
|
const logical = applyRotation(sx, sy, sourceRotation);
|
||||||
|
const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation);
|
||||||
|
|
||||||
|
const targetNormX = (targetCentered.nx + 1) / 2;
|
||||||
|
const targetNormY = (targetCentered.ny + 1) / 2;
|
||||||
|
|
||||||
|
const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX));
|
||||||
|
const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY));
|
||||||
|
return { x: targetX, y: targetY };
|
||||||
|
},
|
||||||
|
[getPaneRotation, getPanBounds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const centerPanForZoom = useCallback(
|
||||||
|
(pane: Pane, zoomValue: number) => {
|
||||||
|
const bounds = getPanBounds(pane, zoomValue);
|
||||||
|
const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) };
|
||||||
|
if (pane === 'base') {
|
||||||
|
setBasePan(center);
|
||||||
|
} else {
|
||||||
|
setComparisonPan(center);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getPanBounds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPanToTopLeft = useCallback((pane: Pane) => {
|
||||||
|
if (pane === 'base') {
|
||||||
|
setBasePan({ x: 0, y: 0 });
|
||||||
|
} else {
|
||||||
|
setComparisonPan({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clampPanForZoom = useCallback(
|
||||||
|
(pane: Pane, zoomValue: number) => {
|
||||||
|
const bounds = getPanBounds(pane, zoomValue);
|
||||||
|
const current = pane === 'base' ? basePan : comparisonPan;
|
||||||
|
const clamped = {
|
||||||
|
x: Math.max(0, Math.min(bounds.maxX, current.x)),
|
||||||
|
y: Math.max(0, Math.min(bounds.maxY, current.y)),
|
||||||
|
};
|
||||||
|
if (pane === 'base') {
|
||||||
|
setBasePan(clamped);
|
||||||
|
} else {
|
||||||
|
setComparisonPan(clamped);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[basePan, comparisonPan, getPanBounds]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScrollSync = useCallback(
|
||||||
|
(source: HTMLDivElement | null, target: HTMLDivElement | null) => {
|
||||||
|
if (panDragRef.current.active) return;
|
||||||
|
if (!source || !target || isSyncingRef.current || !isScrollLinked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceIsBase = source === baseScrollRef.current;
|
||||||
|
const sourceKey = sourceIsBase ? 'base' : 'comparison';
|
||||||
|
|
||||||
|
// Only sync if this scroll was initiated by the user (wheel/scrollbar/keyboard),
|
||||||
|
// not by our own programmatic scrolls.
|
||||||
|
if (!userScrollRef.current[sourceKey]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastActivePaneRef.current = sourceIsBase ? 'base' : 'comparison';
|
||||||
|
|
||||||
|
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||||
|
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||||
|
|
||||||
|
// Use pixel anchors captured at link time to preserve offset
|
||||||
|
const deltaPx = sourceIsBase
|
||||||
|
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||||
|
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||||
|
|
||||||
|
const rawDesired = mappedTop + deltaPx;
|
||||||
|
const desiredTop = Math.max(0, Math.min(targetVerticalRange, rawDesired));
|
||||||
|
|
||||||
|
// If the mapping requests a position beyond target bounds and the target is already
|
||||||
|
// at that bound, skip writing to avoid any subtle feedback that could impede
|
||||||
|
// continued scrolling in the source pane.
|
||||||
|
const atTopBound = desiredTop === 0 && target.scrollTop === 0 && rawDesired < 0;
|
||||||
|
const atBottomBound = desiredTop === targetVerticalRange && target.scrollTop === targetVerticalRange && rawDesired > targetVerticalRange;
|
||||||
|
if (atTopBound || atBottomBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIsBase = target === baseScrollRef.current;
|
||||||
|
const key = targetIsBase ? 'base' : 'comparison';
|
||||||
|
|
||||||
|
desiredTopRef.current[key] = desiredTop;
|
||||||
|
if (syncRafRef.current[key] == null) {
|
||||||
|
syncRafRef.current[key] = requestAnimationFrame(() => {
|
||||||
|
const el = targetIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||||
|
const top = desiredTopRef.current[key] ?? 0;
|
||||||
|
if (el) {
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
el.scrollTop = top;
|
||||||
|
}
|
||||||
|
syncRafRef.current[key] = null;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track user-initiated scroll state per pane
|
||||||
|
useEffect(() => {
|
||||||
|
const baseEl = baseScrollRef.current;
|
||||||
|
const compEl = comparisonScrollRef.current;
|
||||||
|
if (!baseEl || !compEl) return;
|
||||||
|
|
||||||
|
const onUserScrollStartBase = () => { userScrollRef.current.base = true; };
|
||||||
|
const onUserScrollStartComp = () => { userScrollRef.current.comparison = true; };
|
||||||
|
const onUserScrollEndBase = () => { userScrollRef.current.base = false; };
|
||||||
|
const onUserScrollEndComp = () => { userScrollRef.current.comparison = false; };
|
||||||
|
|
||||||
|
const addUserListeners = (el: HTMLDivElement, onStart: () => void, onEnd: () => void) => {
|
||||||
|
el.addEventListener('wheel', onStart, { passive: true });
|
||||||
|
el.addEventListener('mousedown', onStart, { passive: true });
|
||||||
|
el.addEventListener('touchstart', onStart, { passive: true });
|
||||||
|
// Heuristic: clear the flag shortly after scroll events settle
|
||||||
|
let timeout: number | null = null;
|
||||||
|
const onScroll = () => {
|
||||||
|
// Ignore programmatic scrolls to avoid feedback loops and unnecessary syncing work
|
||||||
|
if (isSyncingRef.current) return;
|
||||||
|
onStart();
|
||||||
|
if (timeout != null) window.clearTimeout(timeout);
|
||||||
|
timeout = window.setTimeout(onEnd, 120);
|
||||||
|
};
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('wheel', onStart as any);
|
||||||
|
el.removeEventListener('mousedown', onStart as any);
|
||||||
|
el.removeEventListener('touchstart', onStart as any);
|
||||||
|
el.removeEventListener('scroll', onScroll as any);
|
||||||
|
if (timeout != null) window.clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupBase = addUserListeners(baseEl, onUserScrollStartBase, onUserScrollEndBase);
|
||||||
|
const cleanupComp = addUserListeners(compEl, onUserScrollStartComp, onUserScrollEndComp);
|
||||||
|
return () => { cleanupBase(); cleanupComp(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helpers for clearer pan-edge overscroll behavior
|
||||||
|
const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => {
|
||||||
|
if (rawY < 0) return rawY; // negative -> scroll up
|
||||||
|
if (rawY > maxY) return rawY - maxY; // positive -> scroll down
|
||||||
|
return 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizeApplyCandidate = useCallback((overflowY: number): number => {
|
||||||
|
const DEADZONE = 32; // pixels
|
||||||
|
if (overflowY < -DEADZONE) return overflowY + DEADZONE;
|
||||||
|
if (overflowY > DEADZONE) return overflowY - DEADZONE;
|
||||||
|
return 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => {
|
||||||
|
const STEP = 48; // pixels per incremental scroll
|
||||||
|
const key = isBase ? 'base' : 'comparison';
|
||||||
|
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
|
||||||
|
const magnitude = Math.abs(deltaSinceLast);
|
||||||
|
if (magnitude < STEP) return;
|
||||||
|
|
||||||
|
const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
|
||||||
|
edgeOverscrollRef.current[key] += stepDelta;
|
||||||
|
|
||||||
|
const prevTop = container.scrollTop;
|
||||||
|
const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
|
||||||
|
if (nextTop === prevTop) return;
|
||||||
|
|
||||||
|
container.scrollTop = nextTop;
|
||||||
|
if (isScrollLinked) {
|
||||||
|
const sourceIsBase = isBase;
|
||||||
|
const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||||
|
if (target) {
|
||||||
|
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||||
|
const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
|
||||||
|
const deltaPx = sourceIsBase
|
||||||
|
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||||
|
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||||
|
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||||
|
target.scrollTop = desiredTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isScrollLinked, mapScrollTopBetweenPanes]);
|
||||||
|
|
||||||
|
const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => {
|
||||||
|
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const overflowY = getVerticalOverflow(rawY, boundsMaxY);
|
||||||
|
const applyCandidate = normalizeApplyCandidate(overflowY);
|
||||||
|
if (applyCandidate !== 0) {
|
||||||
|
applyIncrementalScroll(container, isBase, applyCandidate);
|
||||||
|
} else {
|
||||||
|
// Reset accumulator when back within deadzone
|
||||||
|
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
|
||||||
|
}
|
||||||
|
}, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]);
|
||||||
|
|
||||||
|
const beginPan = useCallback(
|
||||||
|
(pane: Pane, event: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isPanMode) return;
|
||||||
|
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||||
|
if (zoom <= 1) return;
|
||||||
|
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const targetEl = event.target as HTMLElement | null;
|
||||||
|
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||||
|
if (!isOnImage) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
panDragRef.current = {
|
||||||
|
active: true,
|
||||||
|
source: pane,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||||
|
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||||
|
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||||
|
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||||
|
};
|
||||||
|
edgeOverscrollRef.current[pane] = 0;
|
||||||
|
lastActivePaneRef.current = pane;
|
||||||
|
(container as HTMLDivElement).style.cursor = 'grabbing';
|
||||||
|
},
|
||||||
|
[isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan]
|
||||||
|
);
|
||||||
|
|
||||||
|
const continuePan = useCallback(
|
||||||
|
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!isPanMode) return;
|
||||||
|
const drag = panDragRef.current;
|
||||||
|
if (!drag.active || !drag.source) return;
|
||||||
|
|
||||||
|
const dx = event.clientX - drag.startX;
|
||||||
|
const dy = event.clientY - drag.startY;
|
||||||
|
|
||||||
|
const isBase = drag.source === 'base';
|
||||||
|
const bounds = getPanBounds(drag.source);
|
||||||
|
const rawX = drag.startPanX - dx;
|
||||||
|
const rawY = drag.startPanY - dy;
|
||||||
|
const desired = {
|
||||||
|
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||||
|
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
|
||||||
|
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||||
|
|
||||||
|
if (isBase) {
|
||||||
|
setBasePan(desired);
|
||||||
|
} else {
|
||||||
|
setComparisonPan(desired);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const endPan = useCallback(() => {
|
||||||
|
const drag = panDragRef.current;
|
||||||
|
if (!drag.active) return;
|
||||||
|
const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||||
|
if (sourceEl) {
|
||||||
|
const zoom = drag.source === 'base' ? baseZoom : comparisonZoom;
|
||||||
|
(sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : '';
|
||||||
|
}
|
||||||
|
panDragRef.current.active = false;
|
||||||
|
panDragRef.current.source = null;
|
||||||
|
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||||
|
|
||||||
|
const handleWheelZoom = useCallback(
|
||||||
|
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||||
|
if (!event.ctrlKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const key = pane === 'base' ? 'base' : 'comparison';
|
||||||
|
const accum = wheelZoomAccumRef.current;
|
||||||
|
const threshold = 180;
|
||||||
|
accum[key] += event.deltaY;
|
||||||
|
const steps = Math.trunc(Math.abs(accum[key]) / threshold);
|
||||||
|
if (steps <= 0) return;
|
||||||
|
const direction = accum[key] > 0 ? -1 : 1;
|
||||||
|
accum[key] = accum[key] % threshold;
|
||||||
|
const applySteps = (zoom: number) => {
|
||||||
|
let next = zoom;
|
||||||
|
for (let i = 0; i < steps; i += 1) {
|
||||||
|
next = direction > 0
|
||||||
|
? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2))
|
||||||
|
: Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
if (pane === 'base') {
|
||||||
|
const prev = baseZoom;
|
||||||
|
const next = applySteps(prev);
|
||||||
|
setBaseZoom(next);
|
||||||
|
if (next < prev) {
|
||||||
|
centerPanForZoom('base', next);
|
||||||
|
} else {
|
||||||
|
clampPanForZoom('base', next);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const prev = comparisonZoom;
|
||||||
|
const next = applySteps(prev);
|
||||||
|
setComparisonZoom(next);
|
||||||
|
if (next < prev) {
|
||||||
|
centerPanForZoom('comparison', next);
|
||||||
|
} else {
|
||||||
|
clampPanForZoom('comparison', next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the source pane hits its scroll limit but the peer still has room,
|
||||||
|
// propagate the wheel delta to the peer so it continues following.
|
||||||
|
const handleWheelOverscroll = useCallback(
|
||||||
|
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||||
|
if (event.ctrlKey) return; // handled by zoom handler
|
||||||
|
if (!isScrollLinked) return;
|
||||||
|
|
||||||
|
const source = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||||
|
const target = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
|
||||||
|
if (!source || !target) return;
|
||||||
|
|
||||||
|
const deltaY = event.deltaY;
|
||||||
|
if (deltaY === 0) return;
|
||||||
|
|
||||||
|
const sourceMax = Math.max(0, source.scrollHeight - source.clientHeight);
|
||||||
|
const nextSource = Math.max(0, Math.min(sourceMax, source.scrollTop + deltaY));
|
||||||
|
|
||||||
|
// If the source can scroll, let the normal scroll event drive syncing
|
||||||
|
if (nextSource !== source.scrollTop) return;
|
||||||
|
|
||||||
|
// Source is at a bound; push mapped delta into the target
|
||||||
|
const sourceIsBase = pane === 'base';
|
||||||
|
|
||||||
|
// Map the desired new source position (scrollTop + deltaY) into target space
|
||||||
|
const mappedBefore = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||||
|
const mappedAfter = mapScrollTopBetweenPanes(source.scrollTop + deltaY, sourceIsBase);
|
||||||
|
const mappedDelta = mappedAfter - mappedBefore;
|
||||||
|
|
||||||
|
// Include the pixel anchor captured when linking
|
||||||
|
const deltaPx = sourceIsBase
|
||||||
|
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||||
|
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||||
|
|
||||||
|
const targetMax = Math.max(0, target.scrollHeight - target.clientHeight);
|
||||||
|
const desired = Math.max(0, Math.min(targetMax, target.scrollTop + (mappedDelta || deltaY)));
|
||||||
|
|
||||||
|
if (desired !== target.scrollTop) {
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
// Adjust relative to mapped space to keep the anchor consistent
|
||||||
|
const anchored = Math.max(0, Math.min(targetMax, mappedBefore + deltaPx + (mappedDelta || deltaY)));
|
||||||
|
target.scrollTop = anchored;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback(
|
||||||
|
(pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => {
|
||||||
|
if (event.touches.length === 2) {
|
||||||
|
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||||
|
const dx = t1.clientX - t2.clientX;
|
||||||
|
const dy = t1.clientY - t2.clientY;
|
||||||
|
pinchRef.current = {
|
||||||
|
active: true,
|
||||||
|
pane,
|
||||||
|
startDistance: Math.hypot(dx, dy),
|
||||||
|
startZoom: pane === 'base' ? baseZoom : comparisonZoom,
|
||||||
|
};
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.touches.length === 1) {
|
||||||
|
if (!isPanMode) return;
|
||||||
|
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||||
|
if (zoom <= 1) return;
|
||||||
|
const targetEl = event.target as HTMLElement | null;
|
||||||
|
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||||
|
if (!isOnImage) return;
|
||||||
|
const touch = event.touches[0];
|
||||||
|
panDragRef.current = {
|
||||||
|
active: true,
|
||||||
|
source: pane,
|
||||||
|
startX: touch.clientX,
|
||||||
|
startY: touch.clientY,
|
||||||
|
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||||
|
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||||
|
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||||
|
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||||
|
};
|
||||||
|
edgeOverscrollRef.current[pane] = 0;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback(
|
||||||
|
(event: ReactTouchEvent<HTMLDivElement>) => {
|
||||||
|
if (pinchRef.current.active && event.touches.length === 2) {
|
||||||
|
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||||
|
const dx = t1.clientX - t2.clientX;
|
||||||
|
const dy = t1.clientY - t2.clientY;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
const scale = distance / Math.max(1, pinchRef.current.startDistance);
|
||||||
|
const dampened = 1 + (scale - 1) * 0.6;
|
||||||
|
const pane = pinchRef.current.pane!;
|
||||||
|
const startZoom = pinchRef.current.startZoom;
|
||||||
|
const previousZoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||||
|
const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
|
||||||
|
if (pane === 'base') {
|
||||||
|
setBaseZoom(nextZoom);
|
||||||
|
if (nextZoom < previousZoom) {
|
||||||
|
centerPanForZoom('base', nextZoom);
|
||||||
|
} else if (nextZoom > previousZoom) {
|
||||||
|
clampPanForZoom('base', nextZoom);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setComparisonZoom(nextZoom);
|
||||||
|
if (nextZoom < previousZoom) {
|
||||||
|
centerPanForZoom('comparison', nextZoom);
|
||||||
|
} else if (nextZoom > previousZoom) {
|
||||||
|
clampPanForZoom('comparison', nextZoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panDragRef.current.active && event.touches.length === 1) {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
const dx = touch.clientX - panDragRef.current.startX;
|
||||||
|
const dy = touch.clientY - panDragRef.current.startY;
|
||||||
|
const isBase = panDragRef.current.source === 'base';
|
||||||
|
const bounds = getPanBounds(panDragRef.current.source!);
|
||||||
|
const rawX = panDragRef.current.startPanX - dx;
|
||||||
|
const rawY = panDragRef.current.startPanY - dy;
|
||||||
|
const desired = {
|
||||||
|
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||||
|
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||||
|
if (isBase) {
|
||||||
|
setBasePan(desired);
|
||||||
|
} else {
|
||||||
|
setComparisonPan(desired);
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
pinchRef.current.active = false;
|
||||||
|
pinchRef.current.pane = null;
|
||||||
|
panDragRef.current.active = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-toggle Pan Mode based on zoom level
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldPan = baseZoom > 1 || comparisonZoom > 1;
|
||||||
|
if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
|
||||||
|
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||||
|
|
||||||
|
// When new pages render and extend scrollHeight, re-apply the mapping so
|
||||||
|
// the follower continues tracking instead of getting stuck at its prior max.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isScrollLinked) return;
|
||||||
|
const sourceIsBase = lastActivePaneRef.current === 'base';
|
||||||
|
const source = sourceIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||||
|
const target = sourceIsBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||||
|
if (!source || !target) return;
|
||||||
|
|
||||||
|
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||||
|
const deltaPx = sourceIsBase
|
||||||
|
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||||
|
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||||
|
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||||
|
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||||
|
|
||||||
|
if (Math.abs(target.scrollTop - desiredTop) > 1) {
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
target.scrollTop = desiredTop;
|
||||||
|
requestAnimationFrame(() => { isSyncingRef.current = false; });
|
||||||
|
}
|
||||||
|
}, [basePages.length, comparisonPages.length, isScrollLinked, mapScrollTopBetweenPanes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (isScrollLinked) return;
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const tag = (target?.tagName || '').toLowerCase();
|
||||||
|
const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true');
|
||||||
|
if (isEditable) return;
|
||||||
|
|
||||||
|
const baseEl = baseScrollRef.current;
|
||||||
|
const compEl = comparisonScrollRef.current;
|
||||||
|
if (!baseEl || !compEl) return;
|
||||||
|
|
||||||
|
const STEP = 80;
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.key === 'ArrowDown' ? STEP : -STEP;
|
||||||
|
isSyncingRef.current = true;
|
||||||
|
baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight));
|
||||||
|
compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isSyncingRef.current = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [isScrollLinked]);
|
||||||
|
|
||||||
|
const captureScrollLinkDelta = useCallback(() => {
|
||||||
|
const baseEl = baseScrollRef.current;
|
||||||
|
const compEl = comparisonScrollRef.current;
|
||||||
|
if (!baseEl || !compEl) {
|
||||||
|
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||||
|
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight);
|
||||||
|
const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight);
|
||||||
|
const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth);
|
||||||
|
const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth);
|
||||||
|
|
||||||
|
const baseV = baseEl.scrollTop / baseVMax;
|
||||||
|
const compV = compEl.scrollTop / compVMax;
|
||||||
|
const baseH = baseEl.scrollLeft / baseHMax;
|
||||||
|
const compH = compEl.scrollLeft / compHMax;
|
||||||
|
|
||||||
|
scrollLinkDeltaRef.current = {
|
||||||
|
vertical: compV - baseV,
|
||||||
|
horizontal: compH - baseH,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Capture pixel anchors in mapped space
|
||||||
|
const mappedBaseToComp = mapScrollTopBetweenPanes(baseEl.scrollTop, true);
|
||||||
|
const mappedCompToBase = mapScrollTopBetweenPanes(compEl.scrollTop, false);
|
||||||
|
scrollLinkAnchorsRef.current = {
|
||||||
|
deltaPixelsBaseToComp: compEl.scrollTop - mappedBaseToComp,
|
||||||
|
deltaPixelsCompToBase: baseEl.scrollTop - mappedCompToBase,
|
||||||
|
};
|
||||||
|
}, [mapScrollTopBetweenPanes]);
|
||||||
|
|
||||||
|
const clearScrollLinkDelta = useCallback(() => {
|
||||||
|
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||||
|
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
setLayout,
|
||||||
|
toggleLayout,
|
||||||
|
baseScrollRef,
|
||||||
|
comparisonScrollRef,
|
||||||
|
isScrollLinked,
|
||||||
|
setIsScrollLinked,
|
||||||
|
captureScrollLinkDelta,
|
||||||
|
clearScrollLinkDelta,
|
||||||
|
isPanMode,
|
||||||
|
setIsPanMode,
|
||||||
|
baseZoom,
|
||||||
|
setBaseZoom,
|
||||||
|
comparisonZoom,
|
||||||
|
setComparisonZoom,
|
||||||
|
basePan,
|
||||||
|
comparisonPan,
|
||||||
|
setPanToTopLeft,
|
||||||
|
centerPanForZoom,
|
||||||
|
clampPanForZoom,
|
||||||
|
handleScrollSync,
|
||||||
|
beginPan,
|
||||||
|
continuePan,
|
||||||
|
endPan,
|
||||||
|
handleWheelZoom,
|
||||||
|
handleWheelOverscroll,
|
||||||
|
onTouchStart,
|
||||||
|
onTouchMove,
|
||||||
|
onTouchEnd,
|
||||||
|
zoomLimits,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
import { alert } from '@app/components/toast';
|
||||||
|
import type { ToastLocation } from '@app/components/toast/types';
|
||||||
|
import type { RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||||
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||||
|
|
||||||
|
type Pane = 'base' | 'comparison';
|
||||||
|
|
||||||
|
export interface UseCompareRightRailButtonsOptions {
|
||||||
|
layout: 'side-by-side' | 'stacked';
|
||||||
|
toggleLayout: () => void;
|
||||||
|
isPanMode: boolean;
|
||||||
|
setIsPanMode: (value: boolean) => void;
|
||||||
|
baseZoom: number;
|
||||||
|
comparisonZoom: number;
|
||||||
|
setBaseZoom: (value: number) => void;
|
||||||
|
setComparisonZoom: (value: number) => void;
|
||||||
|
setPanToTopLeft: (pane: Pane) => void;
|
||||||
|
centerPanForZoom: (pane: Pane, zoom: number) => void;
|
||||||
|
clampPanForZoom: (pane: Pane, zoom: number) => void;
|
||||||
|
clearScrollLinkDelta: () => void;
|
||||||
|
captureScrollLinkDelta: () => void;
|
||||||
|
isScrollLinked: boolean;
|
||||||
|
setIsScrollLinked: (value: boolean) => void;
|
||||||
|
zoomLimits: { min: number; max: number; step: number };
|
||||||
|
baseScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCompareRightRailButtons = ({
|
||||||
|
layout,
|
||||||
|
toggleLayout,
|
||||||
|
isPanMode,
|
||||||
|
setIsPanMode,
|
||||||
|
baseZoom,
|
||||||
|
comparisonZoom,
|
||||||
|
setBaseZoom,
|
||||||
|
setComparisonZoom,
|
||||||
|
setPanToTopLeft,
|
||||||
|
centerPanForZoom,
|
||||||
|
clampPanForZoom,
|
||||||
|
clearScrollLinkDelta,
|
||||||
|
captureScrollLinkDelta,
|
||||||
|
isScrollLinked,
|
||||||
|
setIsScrollLinked,
|
||||||
|
zoomLimits,
|
||||||
|
baseScrollRef,
|
||||||
|
comparisonScrollRef,
|
||||||
|
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
return useMemo<RightRailButtonWithAction[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'compare-toggle-layout',
|
||||||
|
icon: (
|
||||||
|
<LocalIcon
|
||||||
|
icon={layout === 'side-by-side' ? 'vertical-split-rounded' : 'horizontal-split-rounded'}
|
||||||
|
width="1.5rem"
|
||||||
|
height="1.5rem"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
tooltip: layout === 'side-by-side'
|
||||||
|
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||||
|
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||||
|
ariaLabel: layout === 'side-by-side'
|
||||||
|
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||||
|
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||||
|
section: 'top',
|
||||||
|
order: 10,
|
||||||
|
onClick: toggleLayout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compare-zoom-out',
|
||||||
|
icon: <LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />,
|
||||||
|
tooltip: t('compare.actions.zoomOut', 'Zoom out'),
|
||||||
|
ariaLabel: t('compare.actions.zoomOut', 'Zoom out'),
|
||||||
|
section: 'top',
|
||||||
|
order: 13,
|
||||||
|
onClick: () => {
|
||||||
|
const { min, step } = zoomLimits;
|
||||||
|
const nextBase = Math.max(min, +(baseZoom - step).toFixed(2));
|
||||||
|
const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2));
|
||||||
|
setBaseZoom(nextBase);
|
||||||
|
setComparisonZoom(nextComparison);
|
||||||
|
centerPanForZoom('base', nextBase);
|
||||||
|
centerPanForZoom('comparison', nextComparison);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compare-zoom-in',
|
||||||
|
icon: <LocalIcon icon="zoom-in" width="1.5rem" height="1.5rem" />,
|
||||||
|
tooltip: t('compare.actions.zoomIn', 'Zoom in'),
|
||||||
|
ariaLabel: t('compare.actions.zoomIn', 'Zoom in'),
|
||||||
|
section: 'top',
|
||||||
|
order: 14,
|
||||||
|
onClick: () => {
|
||||||
|
const { max, step } = zoomLimits;
|
||||||
|
const nextBase = Math.min(max, +(baseZoom + step).toFixed(2));
|
||||||
|
const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2));
|
||||||
|
setBaseZoom(nextBase);
|
||||||
|
setComparisonZoom(nextComparison);
|
||||||
|
clampPanForZoom('base', nextBase);
|
||||||
|
clampPanForZoom('comparison', nextComparison);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compare-reset-view',
|
||||||
|
icon: <LocalIcon icon="refresh-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
tooltip: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||||
|
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||||
|
section: 'top',
|
||||||
|
order: 14.5,
|
||||||
|
disabled: baseZoom === 1 && comparisonZoom === 1,
|
||||||
|
onClick: () => {
|
||||||
|
setBaseZoom(1);
|
||||||
|
setComparisonZoom(1);
|
||||||
|
setPanToTopLeft('base');
|
||||||
|
setPanToTopLeft('comparison');
|
||||||
|
clearScrollLinkDelta();
|
||||||
|
// Reset scrollTop for both panes to realign view
|
||||||
|
if (baseScrollRef?.current) {
|
||||||
|
baseScrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
if (comparisonScrollRef?.current) {
|
||||||
|
comparisonScrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compare-toggle-scroll-link',
|
||||||
|
icon: (
|
||||||
|
<LocalIcon
|
||||||
|
icon={isScrollLinked ? 'link-rounded' : 'link-off-rounded'}
|
||||||
|
width="1.5rem"
|
||||||
|
height="1.5rem"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
tooltip: isScrollLinked
|
||||||
|
? t('compare.actions.unlinkScroll', 'Unlink scroll')
|
||||||
|
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||||
|
ariaLabel: isScrollLinked
|
||||||
|
? t('compare.actions.unlinkScroll', 'Unlink scroll')
|
||||||
|
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||||
|
section: 'top',
|
||||||
|
order: 15,
|
||||||
|
onClick: () => {
|
||||||
|
const next = !isScrollLinked;
|
||||||
|
if (next) {
|
||||||
|
captureScrollLinkDelta();
|
||||||
|
} else {
|
||||||
|
if (!isMobile) {
|
||||||
|
alert({
|
||||||
|
alertType: 'neutral',
|
||||||
|
title: t('compare.toasts.unlinkedTitle', 'Independent scroll enabled'),
|
||||||
|
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes when unlinked is off.'),
|
||||||
|
durationMs: 5000,
|
||||||
|
location: 'bottom-center' as ToastLocation,
|
||||||
|
expandable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsScrollLinked(next);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [
|
||||||
|
layout,
|
||||||
|
toggleLayout,
|
||||||
|
isPanMode,
|
||||||
|
setIsPanMode,
|
||||||
|
baseZoom,
|
||||||
|
comparisonZoom,
|
||||||
|
setBaseZoom,
|
||||||
|
setComparisonZoom,
|
||||||
|
centerPanForZoom,
|
||||||
|
clampPanForZoom,
|
||||||
|
setPanToTopLeft,
|
||||||
|
clearScrollLinkDelta,
|
||||||
|
captureScrollLinkDelta,
|
||||||
|
isScrollLinked,
|
||||||
|
setIsScrollLinked,
|
||||||
|
zoomLimits,
|
||||||
|
t,
|
||||||
|
isMobile,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseCompareRightRailButtonsReturn = ReturnType<typeof useCompareRightRailButtons>;
|
||||||
@@ -87,7 +87,7 @@ const FileStatusIndicator = ({
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openFilesModal()}
|
onClick={() => openFilesModal({})}
|
||||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||||
>
|
>
|
||||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||||
@@ -122,7 +122,7 @@ const FileStatusIndicator = ({
|
|||||||
{getPlaceholder() + " "}
|
{getPlaceholder() + " "}
|
||||||
<Anchor
|
<Anchor
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openFilesModal()}
|
onClick={() => openFilesModal({})}
|
||||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||||
>
|
>
|
||||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
{config.steps.map((stepConfig) =>
|
{config.steps.map((stepConfig) =>
|
||||||
steps.create(stepConfig.title, {
|
steps.create(stepConfig.title, {
|
||||||
isVisible: stepConfig.isVisible,
|
isVisible: stepConfig.isVisible,
|
||||||
isCollapsed: stepConfig.isCollapsed,
|
|
||||||
onCollapsedClick: stepConfig.onCollapsedClick,
|
onCollapsedClick: stepConfig.onCollapsedClick,
|
||||||
tooltip: stepConfig.tooltip
|
tooltip: stepConfig.tooltip
|
||||||
}, stepConfig.content)
|
}, stepConfig.content)
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ export const useOverlayPdfsTips = (): TooltipContent => {
|
|||||||
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
|
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
|
||||||
description: t(
|
description: t(
|
||||||
'overlay-pdfs.tooltip.description.text',
|
'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'),
|
title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'),
|
||||||
description: t(
|
description: t(
|
||||||
'overlay-pdfs.tooltip.mode.text',
|
'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: [
|
bullets: [
|
||||||
t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'),
|
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'),
|
title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'),
|
||||||
description: t(
|
description: t(
|
||||||
'overlay-pdfs.tooltip.overlayFiles.text',
|
'overlay-pdfs.tooltip.overlayFiles.text',
|
||||||
'Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
|
'Select one or more PDFs to overlay on the original. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export function useViewerRightRailButtons() {
|
|||||||
render: ({ disabled }) => (
|
render: ({ disabled }) => (
|
||||||
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant={isPanning ? 'filled' : 'subtle'}
|
variant={isPanning ? 'default' : 'subtle'}
|
||||||
color={isPanning ? 'blue' : undefined}
|
color={undefined}
|
||||||
radius="md"
|
radius="md"
|
||||||
className="right-rail-icon"
|
className="right-rail-icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -71,6 +71,7 @@ export function useViewerRightRailButtons() {
|
|||||||
setIsPanning(prev => !prev);
|
setIsPanning(prev => !prev);
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||||
import { useFileActions } from '@app/contexts/FileContext';
|
import { useFileActions } from '@app/contexts/FileContext';
|
||||||
|
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from '@app/types/fileContext';
|
||||||
|
import type { FileId } from '@app/types/file';
|
||||||
import { fileStorage } from '@app/services/fileStorage';
|
import { fileStorage } from '@app/services/fileStorage';
|
||||||
|
|
||||||
interface FilesModalContextType {
|
interface FilesModalContextType {
|
||||||
@@ -19,6 +21,7 @@ const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
|||||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { addFiles } = useFileHandler();
|
const { addFiles } = useFileHandler();
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
|
const fileCtx = useFileContext();
|
||||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||||
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
||||||
@@ -37,16 +40,25 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||||||
onModalClose?.();
|
onModalClose?.();
|
||||||
}, [onModalClose]);
|
}, [onModalClose]);
|
||||||
|
|
||||||
const handleFileUpload = useCallback((files: File[]) => {
|
const handleFileUpload = useCallback(async (files: File[]) => {
|
||||||
if (customHandler) {
|
if (customHandler) {
|
||||||
// Use custom handler for special cases (like page insertion)
|
// Use custom handler for special cases (like page insertion)
|
||||||
customHandler(files, insertAfterPage);
|
customHandler(files, insertAfterPage);
|
||||||
} else {
|
} else {
|
||||||
// Use normal file handling
|
// 1) Add via standard flow (auto-selects new files)
|
||||||
addFiles(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();
|
closeFilesModal();
|
||||||
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
|
}, [addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx]);
|
||||||
|
|
||||||
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
||||||
if (customHandler) {
|
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);
|
console.error('Failed to load files for custom handler:', error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal case - use addStirlingFileStubs to preserve metadata
|
// Normal case - use addStirlingFileStubs to preserve metadata (auto-selects new)
|
||||||
if (actions.addStirlingFileStubs) {
|
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 {
|
} else {
|
||||||
console.error('addStirlingFileStubs action not available');
|
console.error('addStirlingFileStubs action not available');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closeFilesModal();
|
closeFilesModal();
|
||||||
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
|
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage, actions, fileCtx]);
|
||||||
|
|
||||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||||
setOnModalClose(() => callback);
|
setOnModalClose(() => callback);
|
||||||
|
|||||||
@@ -421,4 +421,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
|
|||||||
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
|
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
@@ -122,8 +122,11 @@ import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbe
|
|||||||
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
|
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
|
||||||
import ValidateSignature from "@app/tools/ValidateSignature";
|
import ValidateSignature from "@app/tools/ValidateSignature";
|
||||||
import Automate from "@app/tools/Automate";
|
import Automate from "@app/tools/Automate";
|
||||||
|
import Compare from "@app/tools/Compare";
|
||||||
import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats";
|
import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface TranslatedToolCatalog {
|
export interface TranslatedToolCatalog {
|
||||||
allTools: ToolRegistry;
|
allTools: ToolRegistry;
|
||||||
regularTools: RegularToolRegistry;
|
regularTools: RegularToolRegistry;
|
||||||
@@ -772,13 +775,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
compare: {
|
compare: {
|
||||||
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.compare.title", "Compare"),
|
name: t("home.compare.title", "Compare"),
|
||||||
component: null,
|
component: Compare,
|
||||||
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
|
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,
|
subcategoryId: SubcategoryId.GENERAL,
|
||||||
|
maxFiles: 2,
|
||||||
|
operationConfig: undefined,
|
||||||
|
automationSettings: null,
|
||||||
synonyms: getSynonyms(t, "compare"),
|
synonyms: getSynonyms(t, "compare"),
|
||||||
supportsAutomate: false,
|
supportsAutomate: false
|
||||||
automationSettings: null
|
|
||||||
},
|
},
|
||||||
compress: {
|
compress: {
|
||||||
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||||
|
import { appendWord as sharedAppendWord } from '@app/utils/textDiff';
|
||||||
|
import { PARAGRAPH_SENTINEL } from '@app/types/compare';
|
||||||
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
|
import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import type {
|
||||||
|
CompareChange,
|
||||||
|
CompareDiffToken,
|
||||||
|
CompareResultData,
|
||||||
|
TokenBoundingBox,
|
||||||
|
CompareParagraph,
|
||||||
|
} from '@app/types/compare';
|
||||||
|
|
||||||
|
export interface TokenMetadata {
|
||||||
|
page: number;
|
||||||
|
paragraph: number;
|
||||||
|
bbox: TokenBoundingBox | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractedContent {
|
||||||
|
tokens: string[];
|
||||||
|
metadata: TokenMetadata[];
|
||||||
|
pageSizes: { width: number; height: number }[];
|
||||||
|
paragraphs: CompareParagraph[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
|
||||||
|
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
|
||||||
|
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
|
||||||
|
let lastMeasurementFont = '';
|
||||||
|
|
||||||
|
const DEFAULT_CHAR_WIDTH = 1;
|
||||||
|
const DEFAULT_SPACE_WIDTH = 0.33;
|
||||||
|
|
||||||
|
export const measureTextWidth = (fontSpec: string, text: string): number => {
|
||||||
|
if (!measurementContext) {
|
||||||
|
if (!text) return 0;
|
||||||
|
if (text === ' ') return DEFAULT_SPACE_WIDTH;
|
||||||
|
return text.length * DEFAULT_CHAR_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMeasurementFont !== fontSpec) {
|
||||||
|
measurementContext.font = fontSpec;
|
||||||
|
lastMeasurementFont = fontSpec;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${fontSpec}|${text}`;
|
||||||
|
const cached = textMeasurementCache?.get(key);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = measurementContext.measureText(text).width || 0;
|
||||||
|
textMeasurementCache?.set(key, width);
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appendWord = (existing: string, word: string) => {
|
||||||
|
if (!existing) {
|
||||||
|
return sharedAppendWord('', word);
|
||||||
|
}
|
||||||
|
return sharedAppendWord(existing, word);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
|
||||||
|
return tokens.reduce(
|
||||||
|
(totals, token) => {
|
||||||
|
if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
switch (token.type) {
|
||||||
|
case 'added':
|
||||||
|
totals.added += 1;
|
||||||
|
break;
|
||||||
|
case 'removed':
|
||||||
|
totals.removed += 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
totals.unchanged += 1;
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
},
|
||||||
|
{ added: 0, removed: 0, unchanged: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildChanges = (
|
||||||
|
tokens: CompareDiffToken[],
|
||||||
|
baseMetadata: TokenMetadata[],
|
||||||
|
comparisonMetadata: TokenMetadata[]
|
||||||
|
): CompareChange[] => {
|
||||||
|
const changes: CompareChange[] = [];
|
||||||
|
let baseIndex = 0;
|
||||||
|
let comparisonIndex = 0;
|
||||||
|
let current: CompareChange | null = null;
|
||||||
|
let currentBaseParagraph: number | null = null;
|
||||||
|
let currentComparisonParagraph: number | null = null;
|
||||||
|
|
||||||
|
const ensureCurrent = (): CompareChange => {
|
||||||
|
if (!current) {
|
||||||
|
current = {
|
||||||
|
id: `change-${changes.length}`,
|
||||||
|
base: null,
|
||||||
|
comparison: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (current) {
|
||||||
|
if (current.base) {
|
||||||
|
current.base.text = current.base.text.trim();
|
||||||
|
}
|
||||||
|
if (current.comparison) {
|
||||||
|
current.comparison.text = current.comparison.text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
|
||||||
|
changes.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = null;
|
||||||
|
currentBaseParagraph = null;
|
||||||
|
currentComparisonParagraph = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.type === 'removed') {
|
||||||
|
const meta = baseMetadata[baseIndex] ?? null;
|
||||||
|
const active = ensureCurrent();
|
||||||
|
const paragraph = meta?.paragraph ?? null;
|
||||||
|
if (!active.base) {
|
||||||
|
active.base = {
|
||||||
|
text: token.text,
|
||||||
|
page: meta?.page ?? null,
|
||||||
|
paragraph: meta?.paragraph ?? null,
|
||||||
|
};
|
||||||
|
currentBaseParagraph = paragraph;
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
paragraph !== null &&
|
||||||
|
currentBaseParagraph !== null &&
|
||||||
|
paragraph !== currentBaseParagraph &&
|
||||||
|
active.base.text.trim().length > 0
|
||||||
|
) {
|
||||||
|
flush();
|
||||||
|
const next = ensureCurrent();
|
||||||
|
next.base = {
|
||||||
|
text: token.text,
|
||||||
|
page: meta?.page ?? null,
|
||||||
|
paragraph: paragraph,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
active.base.text = appendWord(active.base.text, token.text);
|
||||||
|
}
|
||||||
|
if (meta && active.base.page === null) {
|
||||||
|
active.base.page = meta.page;
|
||||||
|
}
|
||||||
|
if (meta && active.base.paragraph === null) {
|
||||||
|
active.base.paragraph = meta.paragraph;
|
||||||
|
}
|
||||||
|
if (paragraph !== null) {
|
||||||
|
currentBaseParagraph = paragraph;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (baseIndex < baseMetadata.length) {
|
||||||
|
baseIndex += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'added') {
|
||||||
|
const meta = comparisonMetadata[comparisonIndex] ?? null;
|
||||||
|
const active = ensureCurrent();
|
||||||
|
const paragraph = meta?.paragraph ?? null;
|
||||||
|
if (!active.comparison) {
|
||||||
|
active.comparison = {
|
||||||
|
text: token.text,
|
||||||
|
page: meta?.page ?? null,
|
||||||
|
paragraph: meta?.paragraph ?? null,
|
||||||
|
};
|
||||||
|
currentComparisonParagraph = paragraph;
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
paragraph !== null &&
|
||||||
|
currentComparisonParagraph !== null &&
|
||||||
|
paragraph !== currentComparisonParagraph &&
|
||||||
|
active.comparison.text.trim().length > 0
|
||||||
|
) {
|
||||||
|
flush();
|
||||||
|
const next = ensureCurrent();
|
||||||
|
next.comparison = {
|
||||||
|
text: token.text,
|
||||||
|
page: meta?.page ?? null,
|
||||||
|
paragraph: paragraph,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
active.comparison.text = appendWord(active.comparison.text, token.text);
|
||||||
|
}
|
||||||
|
if (meta && active.comparison.page === null) {
|
||||||
|
active.comparison.page = meta.page;
|
||||||
|
}
|
||||||
|
if (meta && active.comparison.paragraph === null) {
|
||||||
|
active.comparison.paragraph = meta.paragraph;
|
||||||
|
}
|
||||||
|
if (paragraph !== null) {
|
||||||
|
currentComparisonParagraph = paragraph;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (comparisonIndex < comparisonMetadata.length) {
|
||||||
|
comparisonIndex += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// unchanged token
|
||||||
|
flush();
|
||||||
|
if (baseIndex < baseMetadata.length) {
|
||||||
|
baseIndex += 1;
|
||||||
|
}
|
||||||
|
if (comparisonIndex < comparisonMetadata.length) {
|
||||||
|
comparisonIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush();
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSummaryFile = (result: CompareResultData): File => {
|
||||||
|
const exportPayload = {
|
||||||
|
generatedAt: new Date(result.totals.processedAt).toISOString(),
|
||||||
|
base: {
|
||||||
|
name: result.base.fileName,
|
||||||
|
totalWords: result.base.wordCount,
|
||||||
|
},
|
||||||
|
comparison: {
|
||||||
|
name: result.comparison.fileName,
|
||||||
|
totalWords: result.comparison.wordCount,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
added: result.totals.added,
|
||||||
|
removed: result.totals.removed,
|
||||||
|
unchanged: result.totals.unchanged,
|
||||||
|
durationMs: result.totals.durationMs,
|
||||||
|
},
|
||||||
|
changes: result.changes.map((change) => ({
|
||||||
|
base: change.base,
|
||||||
|
comparison: change.comparison,
|
||||||
|
})),
|
||||||
|
warnings: result.warnings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
|
||||||
|
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
|
||||||
|
|
||||||
|
export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' | undefined => {
|
||||||
|
if (typeof value === 'object' && value !== null && 'code' in value) {
|
||||||
|
const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' }).code;
|
||||||
|
return potentialCode;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers,
|
||||||
|
// returning a mapping to original indices for potential future use.
|
||||||
|
export const filterTokensForDiff = (
|
||||||
|
tokens: string[],
|
||||||
|
metadata: TokenMetadata[],
|
||||||
|
): { tokens: string[]; metadata: TokenMetadata[]; filteredToOriginal: number[] } => {
|
||||||
|
const outTokens: string[] = [];
|
||||||
|
const outMeta: TokenMetadata[] = [];
|
||||||
|
const map: number[] = [];
|
||||||
|
for (let i = 0; i < tokens.length; i += 1) {
|
||||||
|
const t = tokens[i];
|
||||||
|
const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA');
|
||||||
|
if (!isPara) {
|
||||||
|
outTokens.push(t);
|
||||||
|
if (metadata[i]) outMeta.push(metadata[i]);
|
||||||
|
map.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||||
|
disableAutoFetch: true,
|
||||||
|
disableStream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens: string[] = [];
|
||||||
|
const metadata: TokenMetadata[] = [];
|
||||||
|
const pageSizes: { width: number; height: number }[] = [];
|
||||||
|
const paragraphs: CompareParagraph[] = [];
|
||||||
|
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
|
||||||
|
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const content: TextContent = await page.getTextContent({
|
||||||
|
disableCombineTextItems: true,
|
||||||
|
} as Parameters<PDFPageProxy['getTextContent']>[0]);
|
||||||
|
const styles: Record<string, { fontFamily?: string; ascent?: number; descent?: number }> = content.styles ?? {};
|
||||||
|
|
||||||
|
let paragraphIndex = 1;
|
||||||
|
let paragraphBuffer = '';
|
||||||
|
let prevItem: TextItem | null = null;
|
||||||
|
|
||||||
|
pageSizes.push({ width: viewport.width, height: viewport.height });
|
||||||
|
|
||||||
|
const normalizeToken = (s: string) =>
|
||||||
|
s
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
|
||||||
|
.replace(/[“”]/g, '"')
|
||||||
|
.replace(/[‘’]/g, "'")
|
||||||
|
.replace(/[–—]/g, '-')
|
||||||
|
.replace(/\u00A0/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => {
|
||||||
|
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
|
||||||
|
if (hasHardBreak) return true;
|
||||||
|
if (!prev) return false;
|
||||||
|
const prevY = prev.transform[5];
|
||||||
|
const currY = curr.transform[5];
|
||||||
|
const dy = Math.abs(currY - prevY);
|
||||||
|
const currX = curr.transform[4];
|
||||||
|
const prevX = prev.transform[4];
|
||||||
|
const approxLine = Math.max(10, Math.abs((curr as any).height ?? 0) * 0.9);
|
||||||
|
const looksLikeParagraph = dy > approxLine * 1.8;
|
||||||
|
const likelySoftWrap = currX < prevX && dy < approxLine * 0.6;
|
||||||
|
return looksLikeParagraph && !likelySoftWrap;
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_WIDTH = 0.004;
|
||||||
|
const MIN_HORIZONTAL_PAD = 0.0012;
|
||||||
|
const HORIZONTAL_PAD_RATIO = 0.12;
|
||||||
|
const MIN_VERTICAL_PAD = 0.0008;
|
||||||
|
const VERTICAL_PAD_RATIO = 0.18;
|
||||||
|
|
||||||
|
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
|
||||||
|
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
|
||||||
|
|
||||||
|
let expandedLeft = left - horizontalPad;
|
||||||
|
let expandedRight = left + width + horizontalPad;
|
||||||
|
let expandedTop = top - verticalPad;
|
||||||
|
let expandedBottom = top + height + verticalPad;
|
||||||
|
|
||||||
|
if (expandedRight - expandedLeft < MIN_WIDTH) {
|
||||||
|
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
|
||||||
|
expandedLeft -= deficit / 2;
|
||||||
|
expandedRight += deficit / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedLeft = clamp(expandedLeft);
|
||||||
|
expandedRight = clamp(expandedRight);
|
||||||
|
expandedTop = clamp(expandedTop);
|
||||||
|
expandedBottom = clamp(expandedBottom);
|
||||||
|
|
||||||
|
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: expandedLeft,
|
||||||
|
top: expandedTop,
|
||||||
|
width: expandedRight - expandedLeft,
|
||||||
|
height: expandedBottom - expandedTop,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of content.items as TextItem[]) {
|
||||||
|
if (!item?.str) {
|
||||||
|
prevItem = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = item.str;
|
||||||
|
const totalLen = Math.max(rawText.length, 1);
|
||||||
|
const textStyle = item.fontName ? styles[item.fontName] : undefined;
|
||||||
|
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
|
||||||
|
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
|
||||||
|
const fontSpec = `${fontScale}px ${fontFamily}`;
|
||||||
|
|
||||||
|
const weights: number[] = new Array(totalLen);
|
||||||
|
let runningText = '';
|
||||||
|
let previousAdvance = 0;
|
||||||
|
for (let i = 0; i < totalLen; i += 1) {
|
||||||
|
runningText += rawText[i];
|
||||||
|
const advance = measureTextWidth(fontSpec, runningText);
|
||||||
|
let width = advance - previousAdvance;
|
||||||
|
if (!Number.isFinite(width) || width <= 0) {
|
||||||
|
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||||
|
}
|
||||||
|
weights[i] = width;
|
||||||
|
previousAdvance = advance;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
|
||||||
|
for (let i = 0; i < totalLen; i += 1) {
|
||||||
|
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const prefix: number[] = new Array(totalLen + 1);
|
||||||
|
prefix[0] = 0;
|
||||||
|
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
|
||||||
|
const totalWeight = prefix[totalLen] || 1;
|
||||||
|
|
||||||
|
const rawX = item.transform[4];
|
||||||
|
const rawY = item.transform[5];
|
||||||
|
const transformed = [
|
||||||
|
viewport.convertToViewportPoint(rawX, rawY),
|
||||||
|
viewport.convertToViewportPoint(rawX + item.width, rawY),
|
||||||
|
viewport.convertToViewportPoint(rawX, rawY + item.height),
|
||||||
|
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
|
||||||
|
];
|
||||||
|
const xs = transformed.map(([px]) => px);
|
||||||
|
const ys = transformed.map(([, py]) => py);
|
||||||
|
const left = Math.min(...xs);
|
||||||
|
const right = Math.max(...xs);
|
||||||
|
const top = Math.min(...ys);
|
||||||
|
const bottom = Math.max(...ys);
|
||||||
|
|
||||||
|
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
|
||||||
|
prevItem = item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [baselineStart, baselineEnd, verticalEnd] = transformed;
|
||||||
|
const baselineVector: [number, number] = [
|
||||||
|
baselineEnd[0] - baselineStart[0],
|
||||||
|
baselineEnd[1] - baselineStart[1],
|
||||||
|
];
|
||||||
|
const verticalVector: [number, number] = [
|
||||||
|
verticalEnd[0] - baselineStart[0],
|
||||||
|
verticalEnd[1] - baselineStart[1],
|
||||||
|
];
|
||||||
|
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
|
||||||
|
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
|
||||||
|
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
|
||||||
|
|
||||||
|
const font = item.fontName ? styles[item.fontName] : undefined;
|
||||||
|
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
|
||||||
|
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
|
||||||
|
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
|
||||||
|
|
||||||
|
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = wordRegex.exec(rawText)) !== null) {
|
||||||
|
const wordRaw = match[0];
|
||||||
|
const normalizedWord = normalizeToken(wordRaw);
|
||||||
|
if (!normalizedWord) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const startIndex = match.index;
|
||||||
|
const endIndex = startIndex + wordRaw.length;
|
||||||
|
|
||||||
|
const relStart = prefix[startIndex] / totalWeight;
|
||||||
|
const relEnd = prefix[endIndex] / totalWeight;
|
||||||
|
|
||||||
|
let wordLeftAbs: number;
|
||||||
|
let wordRightAbs: number;
|
||||||
|
let wordTopAbs: number;
|
||||||
|
let wordBottomAbs: number;
|
||||||
|
|
||||||
|
if (hasOrientationVectors) {
|
||||||
|
const segStart: [number, number] = [
|
||||||
|
baselineStart[0] + baselineVector[0] * relStart,
|
||||||
|
baselineStart[1] + baselineVector[1] * relStart,
|
||||||
|
];
|
||||||
|
const segEnd: [number, number] = [
|
||||||
|
baselineStart[0] + baselineVector[0] * relEnd,
|
||||||
|
baselineStart[1] + baselineVector[1] * relEnd,
|
||||||
|
];
|
||||||
|
const cornerPoints: Array<[number, number]> = [
|
||||||
|
segStart,
|
||||||
|
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
|
||||||
|
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
|
||||||
|
segEnd,
|
||||||
|
];
|
||||||
|
const cornerXs = cornerPoints.map(([px]) => px);
|
||||||
|
const cornerYs = cornerPoints.map(([, py]) => py);
|
||||||
|
wordLeftAbs = Math.min(...cornerXs);
|
||||||
|
wordRightAbs = Math.max(...cornerXs);
|
||||||
|
wordTopAbs = Math.min(...cornerYs);
|
||||||
|
wordBottomAbs = Math.max(...cornerYs);
|
||||||
|
} else {
|
||||||
|
const segLeftAbs = left + (right - left) * relStart;
|
||||||
|
const segRightAbs = left + (right - left) * relEnd;
|
||||||
|
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
|
||||||
|
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
|
||||||
|
wordTopAbs = top;
|
||||||
|
wordBottomAbs = bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordLeft = clamp(wordLeftAbs / viewport.width);
|
||||||
|
const wordRight = clamp(wordRightAbs / viewport.width);
|
||||||
|
const wordTop = clamp(wordTopAbs / viewport.height);
|
||||||
|
const wordBottom = clamp(wordBottomAbs / viewport.height);
|
||||||
|
const wordWidth = Math.max(0, wordRight - wordLeft);
|
||||||
|
let wordHeight = Math.max(0, wordBottom - wordTop);
|
||||||
|
|
||||||
|
if (wordHeight > 0 && verticalScale < 1) {
|
||||||
|
const midY = (wordTop + wordBottom) / 2;
|
||||||
|
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
|
||||||
|
const half = shrunkHeight / 2;
|
||||||
|
const newTop = clamp(midY - half);
|
||||||
|
const newBottom = clamp(midY + half);
|
||||||
|
wordHeight = Math.max(0, newBottom - newTop);
|
||||||
|
const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight);
|
||||||
|
tokens.push(normalizedWord);
|
||||||
|
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
|
||||||
|
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
|
||||||
|
|
||||||
|
tokens.push(normalizedWord);
|
||||||
|
metadata.push({
|
||||||
|
page: pageIndex,
|
||||||
|
paragraph: paragraphIndex,
|
||||||
|
bbox,
|
||||||
|
});
|
||||||
|
|
||||||
|
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isParagraphBreak(item as TextItem, prevItem)) {
|
||||||
|
if (paragraphBuffer.trim().length > 0) {
|
||||||
|
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||||
|
paragraphBuffer = '';
|
||||||
|
}
|
||||||
|
tokens.push('\uE000PARA');
|
||||||
|
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||||
|
paragraphIndex += 1;
|
||||||
|
}
|
||||||
|
prevItem = item as TextItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paragraphBuffer.trim().length > 0) {
|
||||||
|
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||||
|
paragraphBuffer = '';
|
||||||
|
tokens.push('\uE000PARA');
|
||||||
|
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tokens, metadata, pageSizes, paragraphs };
|
||||||
|
} finally {
|
||||||
|
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
ADDITION_HIGHLIGHT,
|
||||||
|
CompareDiffToken,
|
||||||
|
CompareFilteredTokenInfo,
|
||||||
|
CompareResultData,
|
||||||
|
CompareWorkerRequest,
|
||||||
|
CompareWorkerResponse,
|
||||||
|
CompareWorkerWarnings,
|
||||||
|
REMOVAL_HIGHLIGHT,
|
||||||
|
} from '@app/types/compare';
|
||||||
|
import { CompareParameters } from '@app/hooks/tools/compare/useCompareParameters';
|
||||||
|
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||||
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
|
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||||
|
import {
|
||||||
|
aggregateTotals,
|
||||||
|
buildChanges,
|
||||||
|
createSummaryFile,
|
||||||
|
extractContentFromPdf,
|
||||||
|
getWorkerErrorCode,
|
||||||
|
filterTokensForDiff,
|
||||||
|
} from '@app/hooks/tools/compare/operationUtils';
|
||||||
|
import { alert, dismissToast } from '@app/components/toast';
|
||||||
|
import type { ToastLocation } from '@app/components/toast/types';
|
||||||
|
import CompareWorkerCtor from '@app/workers/compareWorker?worker';
|
||||||
|
const LONG_RUNNING_PAGE_THRESHOLD = 2000;
|
||||||
|
|
||||||
|
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
|
||||||
|
result: CompareResultData | null;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractContentFromPdf moved to utils
|
||||||
|
|
||||||
|
export const useCompareOperation = (): CompareOperationHook => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { selectors } = useFileContext();
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
const previousUrl = useRef<string | null>(null);
|
||||||
|
const activeRunIdRef = useRef(0);
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
type OperationStatus = 'idle' | 'extracting' | 'processing' | 'complete' | 'cancelled' | 'error';
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [statusState, setStatusState] = useState<OperationStatus>('idle');
|
||||||
|
const [statusDetailMs, setStatusDetailMs] = useState<number | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
|
const [downloadFilename, setDownloadFilename] = useState('');
|
||||||
|
const [result, setResult] = useState<CompareResultData | null>(null);
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
const longRunningToastIdRef = useRef<string | null>(null);
|
||||||
|
const dissimilarityToastIdRef = useRef<string | null>(null);
|
||||||
|
const dissimilarityToastShownRef = useRef<boolean>(false);
|
||||||
|
|
||||||
|
const ensureWorker = useCallback(() => {
|
||||||
|
if (!workerRef.current) {
|
||||||
|
workerRef.current = new CompareWorkerCtor();
|
||||||
|
}
|
||||||
|
return workerRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanupDownloadUrl = useCallback(() => {
|
||||||
|
if (previousUrl.current) {
|
||||||
|
URL.revokeObjectURL(previousUrl.current);
|
||||||
|
previousUrl.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetResults = useCallback(() => {
|
||||||
|
setResult(null);
|
||||||
|
setWarnings([]);
|
||||||
|
setFiles([]);
|
||||||
|
cleanupDownloadUrl();
|
||||||
|
setDownloadUrl(null);
|
||||||
|
setDownloadFilename('');
|
||||||
|
setStatusState('idle');
|
||||||
|
setStatusDetailMs(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, [cleanupDownloadUrl]);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runCompareWorker = useCallback(
|
||||||
|
async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings, onChunk?: (chunk: CompareDiffToken[]) => void) => {
|
||||||
|
const worker = ensureWorker();
|
||||||
|
|
||||||
|
return await new Promise<{
|
||||||
|
tokens: CompareDiffToken[];
|
||||||
|
stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number };
|
||||||
|
warnings: string[];
|
||||||
|
}>((resolve, reject) => {
|
||||||
|
const collectedWarnings: string[] = [];
|
||||||
|
const collectedTokens: CompareDiffToken[] = [];
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
|
||||||
|
if (cancelledRef.current) {
|
||||||
|
cleanup();
|
||||||
|
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = event.data;
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'chunk': {
|
||||||
|
if (message.tokens.length > 0) {
|
||||||
|
collectedTokens.push(...message.tokens);
|
||||||
|
onChunk?.(message.tokens);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'success':
|
||||||
|
cleanup();
|
||||||
|
if (longRunningToastIdRef.current) {
|
||||||
|
dismissToast(longRunningToastIdRef.current);
|
||||||
|
longRunningToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
tokens: collectedTokens,
|
||||||
|
stats: message.stats,
|
||||||
|
warnings: collectedWarnings,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
collectedWarnings.push(message.message);
|
||||||
|
break;
|
||||||
|
case 'error': {
|
||||||
|
cleanup();
|
||||||
|
if (longRunningToastIdRef.current) {
|
||||||
|
dismissToast(longRunningToastIdRef.current);
|
||||||
|
longRunningToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' } = new Error(message.message);
|
||||||
|
error.code = message.code;
|
||||||
|
reject(error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (event: ErrorEvent) => {
|
||||||
|
cleanup();
|
||||||
|
if (cancelledRef.current) {
|
||||||
|
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
|
||||||
|
} else {
|
||||||
|
reject(event.error ?? new Error(event.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
worker.removeEventListener('message', handleMessage as EventListener);
|
||||||
|
worker.removeEventListener('error', handleError as EventListener);
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.addEventListener('message', handleMessage as EventListener);
|
||||||
|
worker.addEventListener('error', handleError as EventListener);
|
||||||
|
|
||||||
|
const request: CompareWorkerRequest = {
|
||||||
|
type: 'compare',
|
||||||
|
payload: {
|
||||||
|
baseTokens,
|
||||||
|
comparisonTokens,
|
||||||
|
warnings: warningMessages,
|
||||||
|
// Static worker settings to support large documents
|
||||||
|
settings: {
|
||||||
|
batchSize: 5000,
|
||||||
|
complexThreshold: 120000,
|
||||||
|
maxWordThreshold: 200000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.postMessage(request);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[ensureWorker]
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeOperation = useCallback(
|
||||||
|
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
|
||||||
|
// start new run
|
||||||
|
const runId = ++activeRunIdRef.current;
|
||||||
|
cancelledRef.current = false;
|
||||||
|
if (!params.baseFileId || !params.comparisonFileId) {
|
||||||
|
setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId)
|
||||||
|
?? selectors.getFile(params.baseFileId);
|
||||||
|
const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId)
|
||||||
|
?? selectors.getFile(params.comparisonFileId);
|
||||||
|
|
||||||
|
if (!baseFile || !comparisonFile) {
|
||||||
|
setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setStatusState('extracting');
|
||||||
|
setStatusDetailMs(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setWarnings([]);
|
||||||
|
setResult(null);
|
||||||
|
setFiles([]);
|
||||||
|
cleanupDownloadUrl();
|
||||||
|
setDownloadUrl(null);
|
||||||
|
setDownloadFilename('');
|
||||||
|
|
||||||
|
const warningMessages: CompareWorkerWarnings = {
|
||||||
|
// No accuracy warning any more
|
||||||
|
tooLargeMessage: t(
|
||||||
|
'compare.large.file.message',
|
||||||
|
'These documents are very large; comparison may take several minutes. Please keep this tab open.'
|
||||||
|
),
|
||||||
|
emptyTextMessage: t(
|
||||||
|
'compare.no.text.message',
|
||||||
|
'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.'
|
||||||
|
),
|
||||||
|
tooDissimilarMessage: t(
|
||||||
|
'compare.too.dissimilar.message',
|
||||||
|
'These documents appear highly dissimilar. Comparison was stopped to save time.'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const operationStart = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [baseContent, comparisonContent] = await Promise.all([
|
||||||
|
extractContentFromPdf(baseFile),
|
||||||
|
extractContentFromPdf(comparisonFile),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
|
||||||
|
|
||||||
|
if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) {
|
||||||
|
throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' });
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusState('processing');
|
||||||
|
|
||||||
|
// Filter out paragraph sentinels before diffing to avoid large false-positive runs
|
||||||
|
const baseFiltered = filterTokensForDiff(baseContent.tokens, baseContent.metadata);
|
||||||
|
const comparisonFiltered = filterTokensForDiff(comparisonContent.tokens, comparisonContent.metadata);
|
||||||
|
|
||||||
|
const combinedPageCount =
|
||||||
|
(baseContent.pageSizes?.length ?? 0) + (comparisonContent.pageSizes?.length ?? 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD &&
|
||||||
|
!longRunningToastIdRef.current
|
||||||
|
) {
|
||||||
|
const toastId = alert({
|
||||||
|
alertType: 'neutral',
|
||||||
|
title: t('compare.longJob.title', 'Large comparison in progress'),
|
||||||
|
body: t(
|
||||||
|
'compare.longJob.body',
|
||||||
|
'These PDFs together exceed 2,000 pages. Processing can take several minutes.'
|
||||||
|
),
|
||||||
|
location: 'bottom-right' as ToastLocation,
|
||||||
|
isPersistentPopup: true,
|
||||||
|
expandable: false,
|
||||||
|
});
|
||||||
|
longRunningToastIdRef.current = toastId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic: surface an early warning toast when we observe a very high ratio of differences
|
||||||
|
const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning
|
||||||
|
const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged
|
||||||
|
let observedAddedRemoved = 0;
|
||||||
|
let observedUnchanged = 0;
|
||||||
|
|
||||||
|
const handleEarlyDissimilarity = () => {
|
||||||
|
if (dissimilarityToastShownRef.current || dissimilarityToastIdRef.current) return;
|
||||||
|
const toastId = alert({
|
||||||
|
alertType: 'warning',
|
||||||
|
title: t('compare.earlyDissimilarity.title', 'These PDFs look highly different'),
|
||||||
|
body: t(
|
||||||
|
'compare.earlyDissimilarity.body',
|
||||||
|
"We're seeing very few similarities so far. You can stop the comparison if these aren't related documents."
|
||||||
|
),
|
||||||
|
location: 'bottom-right' as ToastLocation,
|
||||||
|
isPersistentPopup: true,
|
||||||
|
expandable: false,
|
||||||
|
buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'),
|
||||||
|
buttonCallback: () => {
|
||||||
|
try { cancelOperation(); } catch {
|
||||||
|
console.error('Failed to cancel operation');
|
||||||
|
}
|
||||||
|
try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch {
|
||||||
|
console.error('Failed to dispatch clear selected event');
|
||||||
|
}
|
||||||
|
if (dissimilarityToastIdRef.current) {
|
||||||
|
dismissToast(dissimilarityToastIdRef.current);
|
||||||
|
dissimilarityToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dissimilarityToastIdRef.current = toastId || null;
|
||||||
|
dissimilarityToastShownRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { tokens, stats, warnings: workerWarnings } = await runCompareWorker(
|
||||||
|
baseFiltered.tokens,
|
||||||
|
comparisonFiltered.tokens,
|
||||||
|
warningMessages,
|
||||||
|
(chunk) => {
|
||||||
|
// Incremental ratio tracking for early warning
|
||||||
|
for (const tok of chunk) {
|
||||||
|
if (tok.type === 'unchanged') observedUnchanged += 1;
|
||||||
|
else observedAddedRemoved += 1;
|
||||||
|
}
|
||||||
|
const seen = observedAddedRemoved + observedUnchanged;
|
||||||
|
if (
|
||||||
|
!dissimilarityToastShownRef.current &&
|
||||||
|
seen >= EARLY_TOAST_MIN_TOKENS &&
|
||||||
|
observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO
|
||||||
|
) {
|
||||||
|
handleEarlyDissimilarity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
|
||||||
|
|
||||||
|
const baseHasHighlight = new Array<boolean>(baseFiltered.tokens.length).fill(false);
|
||||||
|
const comparisonHasHighlight = new Array<boolean>(comparisonFiltered.tokens.length).fill(false);
|
||||||
|
|
||||||
|
let baseTokenPointer = 0;
|
||||||
|
let comparisonTokenPointer = 0;
|
||||||
|
for (const diffToken of tokens) {
|
||||||
|
if (diffToken.type === 'removed') {
|
||||||
|
if (baseTokenPointer < baseHasHighlight.length) {
|
||||||
|
baseHasHighlight[baseTokenPointer] = true;
|
||||||
|
}
|
||||||
|
baseTokenPointer += 1;
|
||||||
|
} else if (diffToken.type === 'added') {
|
||||||
|
if (comparisonTokenPointer < comparisonHasHighlight.length) {
|
||||||
|
comparisonHasHighlight[comparisonTokenPointer] = true;
|
||||||
|
}
|
||||||
|
comparisonTokenPointer += 1;
|
||||||
|
} else {
|
||||||
|
if (baseTokenPointer < baseHasHighlight.length) {
|
||||||
|
baseTokenPointer += 1;
|
||||||
|
}
|
||||||
|
if (comparisonTokenPointer < comparisonHasHighlight.length) {
|
||||||
|
comparisonTokenPointer += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFilteredTokenData = (
|
||||||
|
tokensList: typeof baseFiltered.tokens,
|
||||||
|
metadataList: typeof baseFiltered.metadata,
|
||||||
|
highlightFlags: boolean[]
|
||||||
|
): CompareFilteredTokenInfo[] =>
|
||||||
|
tokensList.map((token, index) => {
|
||||||
|
const meta = metadataList[index];
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
page: meta?.page ?? null,
|
||||||
|
paragraph: meta?.paragraph ?? null,
|
||||||
|
bbox: meta?.bbox ?? null,
|
||||||
|
hasHighlight: highlightFlags[index] ?? false,
|
||||||
|
metaIndex: index,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = aggregateTotals(tokens);
|
||||||
|
const processedAt = Date.now();
|
||||||
|
|
||||||
|
const baseMetadata = baseFiltered.metadata;
|
||||||
|
const comparisonMetadata = comparisonFiltered.metadata;
|
||||||
|
|
||||||
|
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
|
||||||
|
|
||||||
|
const comparisonResult: CompareResultData = {
|
||||||
|
base: {
|
||||||
|
fileId: baseFile.fileId,
|
||||||
|
fileName: baseFile.name,
|
||||||
|
highlightColor: REMOVAL_HIGHLIGHT,
|
||||||
|
wordCount: stats.baseWordCount,
|
||||||
|
pageSizes: baseContent.pageSizes,
|
||||||
|
},
|
||||||
|
comparison: {
|
||||||
|
fileId: comparisonFile.fileId,
|
||||||
|
fileName: comparisonFile.name,
|
||||||
|
highlightColor: ADDITION_HIGHLIGHT,
|
||||||
|
wordCount: stats.comparisonWordCount,
|
||||||
|
pageSizes: comparisonContent.pageSizes,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
durationMs: stats.durationMs,
|
||||||
|
processedAt,
|
||||||
|
},
|
||||||
|
tokens,
|
||||||
|
tokenMetadata: {
|
||||||
|
base: baseMetadata,
|
||||||
|
comparison: comparisonMetadata,
|
||||||
|
},
|
||||||
|
filteredTokenData: {
|
||||||
|
base: buildFilteredTokenData(baseFiltered.tokens, baseFiltered.metadata, baseHasHighlight),
|
||||||
|
comparison: buildFilteredTokenData(
|
||||||
|
comparisonFiltered.tokens,
|
||||||
|
comparisonFiltered.metadata,
|
||||||
|
comparisonHasHighlight
|
||||||
|
),
|
||||||
|
},
|
||||||
|
sourceTokens: {
|
||||||
|
base: baseContent.tokens,
|
||||||
|
comparison: comparisonContent.tokens,
|
||||||
|
},
|
||||||
|
changes,
|
||||||
|
warnings: workerWarnings,
|
||||||
|
baseParagraphs: baseContent.paragraphs,
|
||||||
|
comparisonParagraphs: comparisonContent.paragraphs,
|
||||||
|
};
|
||||||
|
|
||||||
|
setResult(comparisonResult);
|
||||||
|
setWarnings(workerWarnings);
|
||||||
|
|
||||||
|
const summaryFile = createSummaryFile(comparisonResult);
|
||||||
|
setFiles([summaryFile]);
|
||||||
|
|
||||||
|
cleanupDownloadUrl();
|
||||||
|
const blobUrl = URL.createObjectURL(summaryFile);
|
||||||
|
previousUrl.current = blobUrl;
|
||||||
|
setDownloadUrl(blobUrl);
|
||||||
|
setDownloadFilename(summaryFile.name);
|
||||||
|
|
||||||
|
setStatusState('complete');
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[compare] operation failed', error);
|
||||||
|
const errorCode = getWorkerErrorCode(error);
|
||||||
|
if (errorCode === 'EMPTY_TEXT') {
|
||||||
|
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
|
||||||
|
} else {
|
||||||
|
const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.');
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
setErrorMessage(error.message);
|
||||||
|
} else if (typeof error === 'string' && error.trim().length > 0) {
|
||||||
|
setErrorMessage(error);
|
||||||
|
} else {
|
||||||
|
setErrorMessage(fallbackMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const duration = performance.now() - operationStart;
|
||||||
|
setStatusDetailMs(Math.round(duration));
|
||||||
|
setIsLoading(false);
|
||||||
|
if (longRunningToastIdRef.current) {
|
||||||
|
dismissToast(longRunningToastIdRef.current);
|
||||||
|
longRunningToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
if (dissimilarityToastIdRef.current) {
|
||||||
|
dismissToast(dissimilarityToastIdRef.current);
|
||||||
|
dissimilarityToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
dissimilarityToastShownRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cleanupDownloadUrl, runCompareWorker, selectors, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelOperation = useCallback(() => {
|
||||||
|
if (!isLoading) return;
|
||||||
|
cancelledRef.current = true;
|
||||||
|
setIsLoading(false);
|
||||||
|
setStatusState('cancelled');
|
||||||
|
if (workerRef.current) {
|
||||||
|
try {
|
||||||
|
workerRef.current.terminate();
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
} catch {}
|
||||||
|
workerRef.current = null;
|
||||||
|
}
|
||||||
|
if (longRunningToastIdRef.current) {
|
||||||
|
dismissToast(longRunningToastIdRef.current);
|
||||||
|
longRunningToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
const undoOperation = useCallback(async () => {
|
||||||
|
resetResults();
|
||||||
|
}, [resetResults]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cleanupDownloadUrl();
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.terminate();
|
||||||
|
workerRef.current = null;
|
||||||
|
}
|
||||||
|
if (longRunningToastIdRef.current) {
|
||||||
|
dismissToast(longRunningToastIdRef.current);
|
||||||
|
longRunningToastIdRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [cleanupDownloadUrl]);
|
||||||
|
|
||||||
|
const status = useMemo(() => {
|
||||||
|
const label =
|
||||||
|
statusState === 'idle' ? ''
|
||||||
|
: statusState === 'extracting' ? t('compare.status.extracting', 'Extracting text...')
|
||||||
|
: statusState === 'processing' ? t('compare.status.processing', 'Analyzing differences...')
|
||||||
|
: statusState === 'complete' ? t('compare.status.complete', 'Comparison ready')
|
||||||
|
: statusState === 'cancelled' ? t('operationCancelled', 'Operation cancelled')
|
||||||
|
: '';
|
||||||
|
if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`;
|
||||||
|
return label;
|
||||||
|
}, [statusState, statusDetailMs, t]);
|
||||||
|
|
||||||
|
return useMemo<CompareOperationHook>(
|
||||||
|
() => ({
|
||||||
|
files,
|
||||||
|
thumbnails: [],
|
||||||
|
isGeneratingThumbnails: false,
|
||||||
|
downloadUrl,
|
||||||
|
downloadFilename,
|
||||||
|
isLoading,
|
||||||
|
status,
|
||||||
|
errorMessage,
|
||||||
|
progress: null,
|
||||||
|
executeOperation,
|
||||||
|
resetResults,
|
||||||
|
clearError,
|
||||||
|
cancelOperation,
|
||||||
|
undoOperation,
|
||||||
|
result,
|
||||||
|
warnings,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
cancelOperation,
|
||||||
|
clearError,
|
||||||
|
downloadFilename,
|
||||||
|
downloadUrl,
|
||||||
|
errorMessage,
|
||||||
|
executeOperation,
|
||||||
|
files,
|
||||||
|
isLoading,
|
||||||
|
resetResults,
|
||||||
|
result,
|
||||||
|
status,
|
||||||
|
undoOperation,
|
||||||
|
warnings,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseParametersHook, useBaseParameters } from '@app/hooks/tools/shared/useBaseParameters';
|
||||||
|
import type { FileId } from '@app/types/file';
|
||||||
|
|
||||||
|
export interface CompareParameters {
|
||||||
|
baseFileId: FileId | null;
|
||||||
|
comparisonFileId: FileId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultParameters: CompareParameters = {
|
||||||
|
baseFileId: null,
|
||||||
|
comparisonFileId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompareParametersHook = BaseParametersHook<CompareParameters>;
|
||||||
|
|
||||||
|
export const useCompareParameters = (): CompareParametersHook => {
|
||||||
|
return useBaseParameters({
|
||||||
|
defaultParameters,
|
||||||
|
endpointName: 'compare',
|
||||||
|
validateFn: (params) =>
|
||||||
|
Boolean(params.baseFileId && params.comparisonFileId && params.baseFileId !== params.comparisonFileId),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useFileActions } from '@app/contexts/FileContext';
|
import { useFileActions } from '@app/contexts/FileContext';
|
||||||
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
|
|
||||||
export const useFileHandler = () => {
|
export const useFileHandler = () => {
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}) => {
|
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}): Promise<StirlingFile[]> => {
|
||||||
// Merge default options with passed options - passed options take precedence
|
// Merge default options with passed options - passed options take precedence
|
||||||
const mergedOptions = { selectFiles: true, ...options };
|
const mergedOptions = { selectFiles: true, ...options };
|
||||||
// Let FileContext handle deduplication with quickKey logic
|
// Let FileContext handle deduplication with quickKey logic
|
||||||
await actions.addFiles(files, mergedOptions);
|
const result = await actions.addFiles(files, mergedOptions);
|
||||||
|
return result;
|
||||||
}, [actions.addFiles]);
|
}, [actions.addFiles]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||||
|
import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||||
|
import { PagePreview } from '@app/types/compare';
|
||||||
|
|
||||||
|
const DISPLAY_SCALE = 1;
|
||||||
|
const BATCH_SIZE = 10; // Render 10 pages at a time
|
||||||
|
|
||||||
|
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||||
|
|
||||||
|
interface ProgressivePagePreviewsOptions {
|
||||||
|
file: File | null;
|
||||||
|
enabled: boolean;
|
||||||
|
cacheKey: number | null;
|
||||||
|
visiblePageRange?: { start: number; end: number }; // 0-based page indices
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProgressivePagePreviewsState {
|
||||||
|
pages: PagePreview[];
|
||||||
|
loading: boolean;
|
||||||
|
totalPages: number;
|
||||||
|
loadedPages: Set<number>; // 0-based page indices that have been loaded
|
||||||
|
loadingPages: Set<number>; // 0-based page indices currently being loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProgressivePagePreviews = ({
|
||||||
|
file,
|
||||||
|
enabled,
|
||||||
|
cacheKey,
|
||||||
|
visiblePageRange,
|
||||||
|
}: ProgressivePagePreviewsOptions) => {
|
||||||
|
const [state, setState] = useState<ProgressivePagePreviewsState>({
|
||||||
|
pages: [],
|
||||||
|
loading: false,
|
||||||
|
totalPages: 0,
|
||||||
|
loadedPages: new Set(),
|
||||||
|
loadingPages: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfRef = useRef<PDFDocumentProxy | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const renderPageBatch = useCallback(async (
|
||||||
|
pdf: PDFDocumentProxy,
|
||||||
|
pageNumbers: number[],
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<PagePreview[]> => {
|
||||||
|
const previews: PagePreview[] = [];
|
||||||
|
const dpr = getDevicePixelRatio();
|
||||||
|
const renderScale = Math.max(2, Math.min(3, dpr * 2));
|
||||||
|
|
||||||
|
for (const pageNumber of pageNumbers) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
|
||||||
|
const renderViewport = page.getViewport({ scale: renderScale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
canvas.width = Math.round(renderViewport.width);
|
||||||
|
canvas.height = Math.round(renderViewport.height);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
page.cleanup();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||||
|
previews.push({
|
||||||
|
pageNumber,
|
||||||
|
width: Math.round(displayViewport.width),
|
||||||
|
height: Math.round(displayViewport.height),
|
||||||
|
rotation: (page.rotate || 0) % 360,
|
||||||
|
url: canvas.toDataURL(),
|
||||||
|
});
|
||||||
|
|
||||||
|
page.cleanup();
|
||||||
|
canvas.width = 0;
|
||||||
|
canvas.height = 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[progressive-pages] failed to render page ${pageNumber}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return previews;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPageRange = useCallback(async (
|
||||||
|
startPage: number,
|
||||||
|
endPage: number,
|
||||||
|
signal: AbortSignal
|
||||||
|
) => {
|
||||||
|
// Use the live PDF ref for bounds instead of possibly stale state
|
||||||
|
const totalPages = pdfRef.current?.numPages ?? state.totalPages;
|
||||||
|
if (startPage < 0 || endPage >= totalPages || startPage > endPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which pages need to be loaded
|
||||||
|
const pagesToLoad: number[] = [];
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
if (!state.loadedPages.has(i) && !state.loadingPages.has(i)) {
|
||||||
|
pagesToLoad.push(i + 1); // Convert to 1-based page numbers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pagesToLoad.length === 0) return;
|
||||||
|
|
||||||
|
// Mark pages as loading
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loadingPages: new Set([...prev.loadingPages, ...pagesToLoad.map(p => p - 1)]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfDoc = pdfRef.current;
|
||||||
|
if (!pdfDoc) return;
|
||||||
|
const previews = await renderPageBatch(pdfDoc, pagesToLoad, signal);
|
||||||
|
|
||||||
|
if (!signal.aborted) {
|
||||||
|
setState(prev => {
|
||||||
|
const newPages = [...prev.pages];
|
||||||
|
const newLoadedPages = new Set(prev.loadedPages);
|
||||||
|
const newLoadingPages = new Set(prev.loadingPages);
|
||||||
|
|
||||||
|
// Add new previews and mark as loaded
|
||||||
|
for (const preview of previews) {
|
||||||
|
const pageIndex = preview.pageNumber - 1; // Convert to 0-based
|
||||||
|
newLoadedPages.add(pageIndex);
|
||||||
|
newLoadingPages.delete(pageIndex);
|
||||||
|
|
||||||
|
// Insert preview in correct position
|
||||||
|
const insertIndex = newPages.findIndex(p => p.pageNumber > preview.pageNumber);
|
||||||
|
if (insertIndex === -1) {
|
||||||
|
newPages.push(preview);
|
||||||
|
} else {
|
||||||
|
newPages.splice(insertIndex, 0, preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pages: newPages,
|
||||||
|
loadedPages: newLoadedPages,
|
||||||
|
loadingPages: newLoadingPages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
console.error('[progressive-pages] failed to load page batch:', error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
setState(prev => {
|
||||||
|
const newLoadingPages = new Set(prev.loadingPages);
|
||||||
|
pagesToLoad.forEach(p => newLoadingPages.delete(p - 1));
|
||||||
|
return { ...prev, loadingPages: newLoadingPages };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state.loadedPages, state.loadingPages, state.totalPages, renderPageBatch]);
|
||||||
|
|
||||||
|
// Initialize PDF and load first batch
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (!file || !enabled) {
|
||||||
|
setState({
|
||||||
|
pages: [],
|
||||||
|
loading: false,
|
||||||
|
totalPages: 0,
|
||||||
|
loadedPages: new Set(),
|
||||||
|
loadingPages: new Set(),
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialize = async () => {
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||||
|
disableAutoFetch: true,
|
||||||
|
disableStream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfRef.current = pdf;
|
||||||
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
totalPages,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load first batch of pages using a real abort controller
|
||||||
|
const initAbort = new AbortController();
|
||||||
|
const firstBatchEnd = Math.min(BATCH_SIZE - 1, totalPages - 1);
|
||||||
|
await loadPageRange(0, firstBatchEnd, initAbort.signal);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[progressive-pages] failed to initialize PDF:', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
loading: false,
|
||||||
|
totalPages: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initialize();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (pdfRef.current) {
|
||||||
|
pdfWorkerManager.destroyDocument(pdfRef.current);
|
||||||
|
pdfRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [file, enabled, cacheKey, loadPageRange]);
|
||||||
|
|
||||||
|
// Load pages based on visible range
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visiblePageRange || state.totalPages === 0) return;
|
||||||
|
|
||||||
|
const { start, end } = visiblePageRange;
|
||||||
|
const startPage = Math.max(0, start - 5); // Add buffer before
|
||||||
|
const endPage = Math.min(state.totalPages - 1, end + 5); // Add buffer after
|
||||||
|
|
||||||
|
// Cancel previous loading
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
loadPageRange(startPage, endPage, abortController.signal);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [visiblePageRange, state.totalPages, loadPageRange]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
if (pdfRef.current) {
|
||||||
|
pdfWorkerManager.destroyDocument(pdfRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages: state.pages,
|
||||||
|
loading: state.loading,
|
||||||
|
totalPages: state.totalPages,
|
||||||
|
loadedPages: state.loadedPages,
|
||||||
|
loadingPages: state.loadingPages,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseProgressivePagePreviewsReturn = ReturnType<typeof useProgressivePagePreviews>;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMediaQuery } from '@mantine/hooks';
|
|
||||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||||
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||||
|
|
||||||
export function useShouldShowWelcomeModal(): boolean {
|
export function useShouldShowWelcomeModal(): boolean {
|
||||||
const { preferences } = usePreferences();
|
const { preferences } = usePreferences();
|
||||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return !preferences.hasCompletedOnboarding
|
return !preferences.hasCompletedOnboarding
|
||||||
&& preferences.toolPanelModePromptSeen
|
&& preferences.toolPanelModePromptSeen
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
|
|||||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||||
import { BASE_PATH } from "@app/constants/app";
|
import { BASE_PATH } from "@app/constants/app";
|
||||||
import { useBaseUrl } from "@app/hooks/useBaseUrl";
|
import { useBaseUrl } from "@app/hooks/useBaseUrl";
|
||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export default function HomePage() {
|
|||||||
const { openFilesModal } = useFilesModalContext();
|
const { openFilesModal } = useFilesModalContext();
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
const isMobile = useIsMobile();
|
||||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||||
const isProgrammaticScroll = useRef(false);
|
const isProgrammaticScroll = useRef(false);
|
||||||
|
|||||||
@@ -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 */
|
/* CSS variables for Tailwind + Mantine integration */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -174,6 +190,7 @@
|
|||||||
--right-rail-foreground: #E3E4E5; /* panel behind custom tool icons */
|
--right-rail-foreground: #E3E4E5; /* panel behind custom tool icons */
|
||||||
--right-rail-icon: #4B5563; /* icon color */
|
--right-rail-icon: #4B5563; /* icon color */
|
||||||
--right-rail-icon-disabled: #CECECE;/* disabled icon */
|
--right-rail-icon-disabled: #CECECE;/* disabled icon */
|
||||||
|
--right-rail-pan-active-bg: #EAEAEA;
|
||||||
|
|
||||||
/* Colors for tooltips */
|
/* Colors for tooltips */
|
||||||
--tooltip-title-bg: #DBEFFF;
|
--tooltip-title-bg: #DBEFFF;
|
||||||
@@ -284,6 +301,17 @@
|
|||||||
--pdf-light-report-container-bg: 249 250 251;
|
--pdf-light-report-container-bg: 249 250 251;
|
||||||
--pdf-light-simulated-page-bg: 255 255 255;
|
--pdf-light-simulated-page-bg: 255 255 255;
|
||||||
--pdf-light-simulated-page-text: 15 23 42;
|
--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"] {
|
[data-mantine-color-scheme="dark"] {
|
||||||
@@ -415,6 +443,7 @@
|
|||||||
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
|
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
|
||||||
--right-rail-icon: #BCBEBF; /* icon color */
|
--right-rail-icon: #BCBEBF; /* icon color */
|
||||||
--right-rail-icon-disabled: #43464B;/* disabled icon */
|
--right-rail-icon-disabled: #43464B;/* disabled icon */
|
||||||
|
--right-rail-pan-active-bg: #EAEAEA;
|
||||||
|
|
||||||
/* Dark mode tooltip colors */
|
/* Dark mode tooltip colors */
|
||||||
--tooltip-title-bg: #4B525A;
|
--tooltip-title-bg: #4B525A;
|
||||||
@@ -428,6 +457,10 @@
|
|||||||
--text-brand: var(--color-gray-800);
|
--text-brand: var(--color-gray-800);
|
||||||
--text-brand-accent: #EF4444;
|
--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 */
|
/* container */
|
||||||
--landing-paper-bg: #171A1F;
|
--landing-paper-bg: #171A1F;
|
||||||
--landing-inner-paper-bg: var(--bg-raised);
|
--landing-inner-paper-bg: var(--bg-raised);
|
||||||
@@ -500,6 +533,17 @@
|
|||||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
|
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
|
||||||
--modal-content-bg: #2A2F36;
|
--modal-content-bg: #2A2F36;
|
||||||
--modal-header-border: rgba(255, 255, 255, 0.08);
|
--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 */
|
/* Dropzone drop state styling */
|
||||||
|
|||||||
@@ -0,0 +1,596 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
|
||||||
|
import { Box, Group, Stack, Text, Button, Modal, ActionIcon } from '@mantine/core';
|
||||||
|
import SwapVertRoundedIcon from '@mui/icons-material/SwapVertRounded';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||||
|
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
|
||||||
|
import { BaseToolProps, ToolComponent } from '@app/types/tool';
|
||||||
|
import {
|
||||||
|
useCompareParameters,
|
||||||
|
defaultParameters as compareDefaultParameters,
|
||||||
|
} from '@app/hooks/tools/compare/useCompareParameters';
|
||||||
|
import {
|
||||||
|
useCompareOperation,
|
||||||
|
CompareOperationHook,
|
||||||
|
} from '@app/hooks/tools/compare/useCompareOperation';
|
||||||
|
import CompareWorkbenchView from '@app/components/tools/compare/CompareWorkbenchView';
|
||||||
|
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||||
|
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||||
|
import { useFileContext, useFileState } from '@app/contexts/file/fileHooks';
|
||||||
|
import type { FileId } from '@app/types/file';
|
||||||
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
|
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
|
||||||
|
import type { CompareWorkbenchData } from '@app/types/compare';
|
||||||
|
import FitText from '@app/components/shared/FitText';
|
||||||
|
import { getDefaultWorkbench } from '@app/types/workbench';
|
||||||
|
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||||
|
|
||||||
|
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
|
||||||
|
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
|
||||||
|
|
||||||
|
const Compare = (props: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { actions: navigationActions } = useNavigationActions();
|
||||||
|
const {
|
||||||
|
registerCustomWorkbenchView,
|
||||||
|
unregisterCustomWorkbenchView,
|
||||||
|
setCustomWorkbenchViewData,
|
||||||
|
clearCustomWorkbenchViewData,
|
||||||
|
} = useToolWorkflow();
|
||||||
|
const { selectors, actions: fileActions } = useFileContext();
|
||||||
|
const { state: fileState } = useFileState();
|
||||||
|
const { openFilesModal } = useFilesModalContext();
|
||||||
|
|
||||||
|
const base = useBaseTool(
|
||||||
|
'compare',
|
||||||
|
useCompareParameters,
|
||||||
|
useCompareOperation,
|
||||||
|
props,
|
||||||
|
{ minFiles: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const operation = base.operation as CompareOperationHook;
|
||||||
|
const params = base.params.parameters;
|
||||||
|
|
||||||
|
const compareIcon = useMemo(() => <CompareRoundedIcon fontSize="small" />, []);
|
||||||
|
const [swapConfirmOpen, setSwapConfirmOpen] = useState(false);
|
||||||
|
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||||
|
const performClearSelected = useCallback(() => {
|
||||||
|
try { base.operation.cancelOperation(); } catch { console.error('Failed to cancel operation'); }
|
||||||
|
try { base.operation.resetResults(); } catch { console.error('Failed to reset results'); }
|
||||||
|
base.params.setParameters(prev => ({ ...prev, baseFileId: null, comparisonFileId: null }));
|
||||||
|
try { fileActions.clearSelections(); } catch { console.error('Failed to clear selections'); }
|
||||||
|
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
|
||||||
|
navigationActions.setWorkbench(getDefaultWorkbench());
|
||||||
|
}, [base.operation, base.params, clearCustomWorkbenchViewData, fileActions, navigationActions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
performClearSelected();
|
||||||
|
};
|
||||||
|
window.addEventListener('compare:clear-selected', handler as unknown as EventListener);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('compare:clear-selected', handler as unknown as EventListener);
|
||||||
|
};
|
||||||
|
}, [performClearSelected]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerCustomWorkbenchView({
|
||||||
|
id: CUSTOM_VIEW_ID,
|
||||||
|
workbenchId: CUSTOM_WORKBENCH_ID,
|
||||||
|
// Use a static label at registration time to avoid re-registering on i18n changes
|
||||||
|
label: 'Compare view',
|
||||||
|
icon: compareIcon,
|
||||||
|
component: CompareWorkbenchView,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregisterCustomWorkbenchView(CUSTOM_VIEW_ID);
|
||||||
|
};
|
||||||
|
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-map from workbench selection: always reflect the first two selected files in order.
|
||||||
|
// This also handles deselection by promoting the remaining selection to base and clearing comparison.
|
||||||
|
useEffect(() => {
|
||||||
|
// Use selected IDs directly from state so it works even if File objects aren't loaded yet
|
||||||
|
const selectedIds = (fileState.ui.selectedFileIds as FileId[]) ?? [];
|
||||||
|
|
||||||
|
// Determine next base: keep current if still selected; otherwise use the first selected id
|
||||||
|
const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId)
|
||||||
|
? (params.baseFileId as FileId)
|
||||||
|
: (selectedIds[0] ?? null);
|
||||||
|
|
||||||
|
// Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id
|
||||||
|
let nextComp: FileId | null = null;
|
||||||
|
if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) {
|
||||||
|
nextComp = params.comparisonFileId as FileId;
|
||||||
|
} else {
|
||||||
|
nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) {
|
||||||
|
base.params.setParameters(prev => ({
|
||||||
|
...prev,
|
||||||
|
baseFileId: nextBase,
|
||||||
|
comparisonFileId: nextComp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [fileState.ui.selectedFileIds, base.params, params.baseFileId, params.comparisonFileId]);
|
||||||
|
|
||||||
|
// Track workbench data and drive loading/result state transitions
|
||||||
|
const lastProcessedAtRef = useRef<number | null>(null);
|
||||||
|
const lastWorkbenchDataRef = useRef<CompareWorkbenchData | null>(null);
|
||||||
|
|
||||||
|
const updateWorkbenchData = useCallback(
|
||||||
|
(data: CompareWorkbenchData) => {
|
||||||
|
const previous = lastWorkbenchDataRef.current;
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
previous.result === data.result &&
|
||||||
|
previous.baseFileId === data.baseFileId &&
|
||||||
|
previous.comparisonFileId === data.comparisonFileId &&
|
||||||
|
previous.isLoading === data.isLoading &&
|
||||||
|
previous.baseLocalFile === data.baseLocalFile &&
|
||||||
|
previous.comparisonLocalFile === data.comparisonLocalFile
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastWorkbenchDataRef.current = data;
|
||||||
|
setCustomWorkbenchViewData(CUSTOM_VIEW_ID, data);
|
||||||
|
},
|
||||||
|
[setCustomWorkbenchViewData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const prepareWorkbenchForRun = useCallback(
|
||||||
|
(
|
||||||
|
baseId: FileId | null,
|
||||||
|
compId: FileId | null,
|
||||||
|
options?: { baseFile?: StirlingFile | null; comparisonFile?: StirlingFile | null }
|
||||||
|
) => {
|
||||||
|
if (!baseId || !compId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = lastWorkbenchDataRef.current;
|
||||||
|
const resolvedBaseFile =
|
||||||
|
options?.baseFile ??
|
||||||
|
(baseId ? selectors.getFile(baseId) : null) ??
|
||||||
|
previous?.baseLocalFile ??
|
||||||
|
null;
|
||||||
|
const resolvedComparisonFile =
|
||||||
|
options?.comparisonFile ??
|
||||||
|
(compId ? selectors.getFile(compId) : null) ??
|
||||||
|
previous?.comparisonLocalFile ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
updateWorkbenchData({
|
||||||
|
result: null,
|
||||||
|
baseFileId: baseId,
|
||||||
|
comparisonFileId: compId,
|
||||||
|
baseLocalFile: resolvedBaseFile,
|
||||||
|
comparisonLocalFile: resolvedComparisonFile,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastProcessedAtRef.current = null;
|
||||||
|
},
|
||||||
|
[selectors, updateWorkbenchData]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const baseFileId = params.baseFileId as FileId | null;
|
||||||
|
const comparisonFileId = params.comparisonFileId as FileId | null;
|
||||||
|
|
||||||
|
if (!baseFileId || !comparisonFileId) {
|
||||||
|
lastProcessedAtRef.current = null;
|
||||||
|
lastWorkbenchDataRef.current = null;
|
||||||
|
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = operation.result;
|
||||||
|
const processedAt = result?.totals.processedAt ?? null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
result &&
|
||||||
|
processedAt !== null &&
|
||||||
|
processedAt !== lastProcessedAtRef.current &&
|
||||||
|
result.base.fileId === baseFileId &&
|
||||||
|
result.comparison.fileId === comparisonFileId
|
||||||
|
) {
|
||||||
|
const previous = lastWorkbenchDataRef.current;
|
||||||
|
const baseLocalFile =
|
||||||
|
(baseFileId ? selectors.getFile(baseFileId) : null) ??
|
||||||
|
previous?.baseLocalFile ??
|
||||||
|
null;
|
||||||
|
const comparisonLocalFile =
|
||||||
|
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
|
||||||
|
previous?.comparisonLocalFile ??
|
||||||
|
null;
|
||||||
|
updateWorkbenchData({
|
||||||
|
result,
|
||||||
|
baseFileId,
|
||||||
|
comparisonFileId,
|
||||||
|
baseLocalFile,
|
||||||
|
comparisonLocalFile,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
lastProcessedAtRef.current = processedAt;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base.operation.isLoading) {
|
||||||
|
const previous = lastWorkbenchDataRef.current;
|
||||||
|
const baseLocalFile =
|
||||||
|
(baseFileId ? selectors.getFile(baseFileId) : null) ??
|
||||||
|
previous?.baseLocalFile ??
|
||||||
|
null;
|
||||||
|
const comparisonLocalFile =
|
||||||
|
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
|
||||||
|
previous?.comparisonLocalFile ??
|
||||||
|
null;
|
||||||
|
updateWorkbenchData({
|
||||||
|
result: null,
|
||||||
|
baseFileId,
|
||||||
|
comparisonFileId,
|
||||||
|
baseLocalFile,
|
||||||
|
comparisonLocalFile,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
base.operation.isLoading,
|
||||||
|
clearCustomWorkbenchViewData,
|
||||||
|
operation.result,
|
||||||
|
params.baseFileId,
|
||||||
|
params.comparisonFileId,
|
||||||
|
selectors,
|
||||||
|
updateWorkbenchData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleExecuteCompare = useCallback(async () => {
|
||||||
|
const baseId = params.baseFileId as FileId | null;
|
||||||
|
const compId = params.comparisonFileId as FileId | null;
|
||||||
|
const baseSel =
|
||||||
|
base.selectedFiles.find((file) => file.fileId === baseId) ??
|
||||||
|
(baseId ? selectors.getFile(baseId) : null);
|
||||||
|
const compSel =
|
||||||
|
base.selectedFiles.find((file) => file.fileId === compId) ??
|
||||||
|
(compId ? selectors.getFile(compId) : null);
|
||||||
|
const selected: StirlingFile[] = [];
|
||||||
|
if (baseSel) selected.push(baseSel);
|
||||||
|
if (compSel) selected.push(compSel);
|
||||||
|
|
||||||
|
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
|
||||||
|
if (baseId && compId) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await operation.executeOperation(
|
||||||
|
{ ...params },
|
||||||
|
selected
|
||||||
|
);
|
||||||
|
}, [base.selectedFiles, navigationActions, operation, params, prepareWorkbenchForRun, selectors]);
|
||||||
|
|
||||||
|
// Run compare with explicit ids (used after swap so we don't depend on async state propagation)
|
||||||
|
const runCompareWithIds = useCallback(async (baseId: FileId | null, compId: FileId | null) => {
|
||||||
|
const nextParams = { ...params, baseFileId: baseId, comparisonFileId: compId };
|
||||||
|
const selected: StirlingFile[] = [];
|
||||||
|
const baseSel =
|
||||||
|
base.selectedFiles.find((file) => file.fileId === baseId) ??
|
||||||
|
(baseId ? selectors.getFile(baseId) : null);
|
||||||
|
const compSel =
|
||||||
|
base.selectedFiles.find((file) => file.fileId === compId) ??
|
||||||
|
(compId ? selectors.getFile(compId) : null);
|
||||||
|
if (baseSel) selected.push(baseSel);
|
||||||
|
if (compSel) selected.push(compSel);
|
||||||
|
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
|
||||||
|
await operation.executeOperation(nextParams, selected);
|
||||||
|
}, [base.selectedFiles, operation, params, prepareWorkbenchForRun, selectors]);
|
||||||
|
|
||||||
|
const performSwap = useCallback(() => {
|
||||||
|
const baseId = params.baseFileId as FileId | null;
|
||||||
|
const compId = params.comparisonFileId as FileId | null;
|
||||||
|
if (!baseId || !compId) return;
|
||||||
|
base.params.setParameters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
baseFileId: compId,
|
||||||
|
comparisonFileId: baseId,
|
||||||
|
}));
|
||||||
|
if (operation.result) {
|
||||||
|
runCompareWithIds(compId, baseId);
|
||||||
|
}
|
||||||
|
}, [base.params, operation.result, params.baseFileId, params.comparisonFileId, runCompareWithIds]);
|
||||||
|
|
||||||
|
// No custom handler; rely on global add flow which auto-selects added files
|
||||||
|
|
||||||
|
const handleSwap = useCallback(() => {
|
||||||
|
const baseId = params.baseFileId as FileId | null;
|
||||||
|
const compId = params.comparisonFileId as FileId | null;
|
||||||
|
if (!baseId || !compId) return;
|
||||||
|
if (operation.result) {
|
||||||
|
setSwapConfirmOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performSwap();
|
||||||
|
}, [operation.result, params.baseFileId, params.comparisonFileId, performSwap]);
|
||||||
|
|
||||||
|
const renderSelectedFile = useCallback(
|
||||||
|
(role: 'base' | 'comparison') => {
|
||||||
|
const fileId = role === 'base' ? params.baseFileId : params.comparisonFileId;
|
||||||
|
const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined;
|
||||||
|
|
||||||
|
// Show add button in base if no base file, or in comparison if base exists but no comparison
|
||||||
|
const shouldShowAddButton =
|
||||||
|
(role === 'base' && !params.baseFileId) ||
|
||||||
|
(role === 'comparison' && params.baseFileId && !params.comparisonFileId);
|
||||||
|
|
||||||
|
if (!stub) {
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
cursor: shouldShowAddButton ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={shouldShowAddButton ? () => openFilesModal({}) : undefined}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
role === 'base' ? 'compare.original.placeholder' : 'compare.edited.placeholder',
|
||||||
|
role === 'base' ? 'Select the original PDF' : 'Select the edited PDF'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
{shouldShowAddButton && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openFilesModal({});
|
||||||
|
}}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<AddIcon fontSize="small" />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Build compact meta line for pages and date
|
||||||
|
const dateMs = (stub?.lastModified || stub?.createdAt) ?? null;
|
||||||
|
const dateText = dateMs
|
||||||
|
? new Date(dateMs).toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' })
|
||||||
|
: '';
|
||||||
|
const pageCount = stub?.processedFile?.totalPages || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
width: '100%',
|
||||||
|
minHeight: "9rem"
|
||||||
|
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group align="flex-start" wrap="nowrap" gap="md">
|
||||||
|
<Box className="compare-tool__thumbnail" style={{ alignSelf: 'center' }}>
|
||||||
|
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
|
||||||
|
</Box>
|
||||||
|
<Stack className="compare-tool__details">
|
||||||
|
<FitText
|
||||||
|
text={stub?.name || ''}
|
||||||
|
minimumFontScale={0.8}
|
||||||
|
lines={3}
|
||||||
|
style={{ fontWeight: 600
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{pageCount && dateText && (
|
||||||
|
<>
|
||||||
|
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{pageCount} {t('compare.pages', 'pages')}
|
||||||
|
<br />
|
||||||
|
{dateText}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[params.baseFileId, params.comparisonFileId, selectors, t, openFilesModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseStub = params.baseFileId ? selectors.getStirlingFileStub(params.baseFileId) : undefined;
|
||||||
|
const compStub = params.comparisonFileId ? selectors.getStirlingFileStub(params.comparisonFileId) : undefined;
|
||||||
|
const canExecute = Boolean(
|
||||||
|
params.baseFileId &&
|
||||||
|
params.comparisonFileId &&
|
||||||
|
params.baseFileId !== params.comparisonFileId &&
|
||||||
|
baseStub &&
|
||||||
|
compStub &&
|
||||||
|
!base.operation.isLoading &&
|
||||||
|
base.endpointEnabled !== false
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId);
|
||||||
|
const hasAnyFiles = selectors.getFiles().length > 0;
|
||||||
|
|
||||||
|
return createToolFlow({
|
||||||
|
files: {
|
||||||
|
selectedFiles: base.selectedFiles,
|
||||||
|
isCollapsed: false,
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: t('compare.selection.originalEditedTitle', 'Select Original and Edited PDFs'),
|
||||||
|
isVisible: true,
|
||||||
|
content: (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: hasBothSelected ? '1fr 2.25rem' : '1fr',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header row: Original PDF + Clear selected aligned to swap column */}
|
||||||
|
<Box
|
||||||
|
style={{ gridColumn: hasBothSelected ? '1 / span 2' : '1', display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.5rem' }}
|
||||||
|
>
|
||||||
|
<Text fw={700} size="sm">{t('compare.original.label', 'Original PDF')}</Text>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
onClick={() => setClearConfirmOpen(true)}
|
||||||
|
disabled={!hasAnyFiles}
|
||||||
|
styles={{ root: { textDecoration: 'underline' } }}
|
||||||
|
style={{
|
||||||
|
background: !hasAnyFiles ? 'transparent' : undefined,
|
||||||
|
color: !hasAnyFiles ? 'var(--spdf-clear-disabled-text)' : undefined
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('compare.clearSelected', 'Clear selected')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
gridColumn: '1',
|
||||||
|
minWidth: 0,
|
||||||
|
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderSelectedFile('base')}
|
||||||
|
<div style={{ height: '0.75rem' }} />
|
||||||
|
{/* Edited PDF section header */}
|
||||||
|
<Text fw={700} size="sm" style={{ marginBottom: '1rem', marginTop: '0.5rem'}}>{t('compare.edited.label', 'Edited PDF')}</Text>
|
||||||
|
{renderSelectedFile('comparison')}
|
||||||
|
</Box>
|
||||||
|
{hasBothSelected && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
gridColumn: '2',
|
||||||
|
gridRow: '2',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleSwap}
|
||||||
|
disabled={!hasBothSelected || base.operation.isLoading}
|
||||||
|
style={{
|
||||||
|
width: '2.25rem',
|
||||||
|
height: '100%',
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border-default)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SwapVertRoundedIcon fontSize="medium" />
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
opened={swapConfirmOpen}
|
||||||
|
onClose={() => setSwapConfirmOpen(false)}
|
||||||
|
title={t('compare.swap.confirmTitle', 'Re-run comparison?')}
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text>{t('compare.swap.confirmBody', 'This will rerun the tool. Are you sure you want to swap the order of Original and Edited?')}</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="light" onClick={() => setSwapConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
onClick={() => {
|
||||||
|
setSwapConfirmOpen(false);
|
||||||
|
performSwap();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('compare.swap.confirm', 'Swap and Re-run')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
opened={clearConfirmOpen}
|
||||||
|
onClose={() => setClearConfirmOpen(false)}
|
||||||
|
title={t('compare.clear.confirmTitle', 'Clear selected PDFs?')}
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text>{t('compare.clear.confirmBody', 'This will close the current comparison and take you back to Active Files.')}</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="light" onClick={() => setClearConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
onClick={() => {
|
||||||
|
setClearConfirmOpen(false);
|
||||||
|
performClearSelected();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('compare.clear.confirm', 'Clear and return')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executeButton: {
|
||||||
|
text: t('compare.cta', 'Compare'),
|
||||||
|
loadingText: t('compare.loading', 'Comparing...'),
|
||||||
|
onClick: handleExecuteCompare,
|
||||||
|
disabled: !canExecute,
|
||||||
|
testId: 'compare-execute',
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
isVisible: false,
|
||||||
|
operation: base.operation,
|
||||||
|
title: t('compare.review.title', 'Comparison Result'),
|
||||||
|
onUndo: base.operation.undoOperation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const CompareTool = Compare as ToolComponent;
|
||||||
|
CompareTool.tool = () => useCompareOperation;
|
||||||
|
CompareTool.getDefaultParameters = () => ({ ...compareDefaultParameters });
|
||||||
|
|
||||||
|
export default CompareTool;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +39,8 @@ const ValidateSignature = (props: BaseToolProps) => {
|
|||||||
const hasResults = operation.results.length > 0;
|
const hasResults = operation.results.length > 0;
|
||||||
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerCustomWorkbenchView({
|
registerCustomWorkbenchView({
|
||||||
id: REPORT_VIEW_ID,
|
id: REPORT_VIEW_ID,
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import type { FileId } from '@app/types/file';
|
||||||
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
|
|
||||||
|
export type CompareDiffTokenType = 'unchanged' | 'removed' | 'added';
|
||||||
|
|
||||||
|
export interface CompareDiffToken {
|
||||||
|
type: CompareDiffTokenType;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REMOVAL_HIGHLIGHT = '#FF3B30';
|
||||||
|
export const ADDITION_HIGHLIGHT = '#34C759';
|
||||||
|
export const PARAGRAPH_SENTINEL = '\uE000¶';
|
||||||
|
|
||||||
|
export interface TokenBoundingBox {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareTokenMetadata {
|
||||||
|
page: number;
|
||||||
|
paragraph: number;
|
||||||
|
bbox: TokenBoundingBox | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparePageSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareDocumentInfo {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
highlightColor: string;
|
||||||
|
wordCount: number;
|
||||||
|
pageSizes: ComparePageSize[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareParagraph {
|
||||||
|
page: number;
|
||||||
|
paragraph: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareFilteredTokenInfo {
|
||||||
|
token: string;
|
||||||
|
page: number | null;
|
||||||
|
paragraph: number | null;
|
||||||
|
bbox: TokenBoundingBox | null;
|
||||||
|
hasHighlight: boolean;
|
||||||
|
metaIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareChangeSide {
|
||||||
|
text: string;
|
||||||
|
page: number | null;
|
||||||
|
paragraph: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareChange {
|
||||||
|
id: string;
|
||||||
|
base: CompareChangeSide | null;
|
||||||
|
comparison: CompareChangeSide | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareResultData {
|
||||||
|
base: CompareDocumentInfo;
|
||||||
|
comparison: CompareDocumentInfo;
|
||||||
|
totals: {
|
||||||
|
added: number;
|
||||||
|
removed: number;
|
||||||
|
unchanged: number;
|
||||||
|
durationMs: number;
|
||||||
|
processedAt: number;
|
||||||
|
};
|
||||||
|
tokens: CompareDiffToken[];
|
||||||
|
tokenMetadata: {
|
||||||
|
base: CompareTokenMetadata[];
|
||||||
|
comparison: CompareTokenMetadata[];
|
||||||
|
};
|
||||||
|
filteredTokenData: {
|
||||||
|
base: CompareFilteredTokenInfo[];
|
||||||
|
comparison: CompareFilteredTokenInfo[];
|
||||||
|
};
|
||||||
|
sourceTokens: {
|
||||||
|
base: string[];
|
||||||
|
comparison: string[];
|
||||||
|
};
|
||||||
|
changes: CompareChange[];
|
||||||
|
warnings: string[];
|
||||||
|
baseParagraphs: CompareParagraph[];
|
||||||
|
comparisonParagraphs: CompareParagraph[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareWorkerWarnings {
|
||||||
|
complexMessage?: string;
|
||||||
|
tooLargeMessage?: string;
|
||||||
|
emptyTextMessage?: string;
|
||||||
|
tooDissimilarMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareWorkerRequest {
|
||||||
|
type: 'compare';
|
||||||
|
payload: {
|
||||||
|
baseTokens: string[];
|
||||||
|
comparisonTokens: string[];
|
||||||
|
warnings: CompareWorkerWarnings;
|
||||||
|
settings?: {
|
||||||
|
batchSize?: number;
|
||||||
|
complexThreshold?: number;
|
||||||
|
maxWordThreshold?: number;
|
||||||
|
// Early-stop and runtime controls (optional)
|
||||||
|
earlyStopEnabled?: boolean;
|
||||||
|
minJaccardUnigram?: number;
|
||||||
|
minJaccardBigram?: number;
|
||||||
|
minTokensForEarlyStop?: number;
|
||||||
|
sampleLimit?: number;
|
||||||
|
runtimeMaxProcessedTokens?: number;
|
||||||
|
runtimeMinUnchangedRatio?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompareWorkerResponse =
|
||||||
|
| {
|
||||||
|
type: 'chunk';
|
||||||
|
tokens: CompareDiffToken[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'success';
|
||||||
|
stats: {
|
||||||
|
baseWordCount: number;
|
||||||
|
comparisonWordCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'warning';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'error';
|
||||||
|
message: string;
|
||||||
|
code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR';
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CompareDocumentPaneProps {
|
||||||
|
pane: 'base' | 'comparison';
|
||||||
|
layout: 'side-by-side' | 'stacked';
|
||||||
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
peerScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
|
||||||
|
handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
|
||||||
|
handleWheelOverscroll: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
|
||||||
|
onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent<HTMLDivElement>) => void;
|
||||||
|
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
|
||||||
|
onTouchEnd: (event: React.TouchEvent<HTMLDivElement>) => void;
|
||||||
|
isPanMode: boolean;
|
||||||
|
zoom: number;
|
||||||
|
title: string;
|
||||||
|
dropdownPlaceholder?: React.ReactNode;
|
||||||
|
changes: Array<{ value: string; label: string; pageNumber?: number }>;
|
||||||
|
onNavigateChange: (id: string, pageNumber?: number) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
processingMessage: string;
|
||||||
|
pages: PagePreview[];
|
||||||
|
pairedPages: PagePreview[];
|
||||||
|
getRowHeightPx: (pageNumber: number) => number;
|
||||||
|
wordHighlightMap: Map<number, WordHighlightEntry[]>;
|
||||||
|
metaIndexToGroupId: Map<number, string>;
|
||||||
|
documentLabel: string;
|
||||||
|
pageLabel: string;
|
||||||
|
altLabel: string;
|
||||||
|
// Page input/navigation props (optional to keep call sites flexible)
|
||||||
|
pageInputValue?: string;
|
||||||
|
onPageInputChange?: (next: string) => void;
|
||||||
|
maxSharedPages?: number; // min(baseTotal, compTotal)
|
||||||
|
renderedPageNumbers?: Set<number>;
|
||||||
|
onVisiblePageChange?: (pane: 'base' | 'comparison', pageNumber: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import types that are referenced in CompareDocumentPaneProps
|
||||||
|
export interface PagePreview {
|
||||||
|
pageNumber: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordHighlightEntry {
|
||||||
|
rect: TokenBoundingBox;
|
||||||
|
metaIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationDropdownProps {
|
||||||
|
changes: Array<{ value: string; label: string; pageNumber?: number }>;
|
||||||
|
placeholder: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onNavigate: (value: string, pageNumber?: number) => void;
|
||||||
|
// Optional: pages that currently have previews rendered (1-based page numbers)
|
||||||
|
renderedPageNumbers?: Set<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pan/Zoom and Compare Workbench shared types (moved out of hooks for reuse)
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
export type ComparePane = 'base' | 'comparison';
|
||||||
|
|
||||||
|
export interface PanState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollLinkDelta {
|
||||||
|
vertical: number;
|
||||||
|
horizontal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrollLinkAnchors {
|
||||||
|
deltaPixelsBaseToComp: number;
|
||||||
|
deltaPixelsCompToBase: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanDragState {
|
||||||
|
active: boolean;
|
||||||
|
source: ComparePane | null;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startPanX: number;
|
||||||
|
startPanY: number;
|
||||||
|
targetStartPanX: number;
|
||||||
|
targetStartPanY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PinchState {
|
||||||
|
active: boolean;
|
||||||
|
pane: ComparePane | null;
|
||||||
|
startDistance: number;
|
||||||
|
startZoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseComparePanZoomOptions {
|
||||||
|
prefersStacked: boolean;
|
||||||
|
basePages: PagePreview[];
|
||||||
|
comparisonPages: PagePreview[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseComparePanZoomReturn {
|
||||||
|
layout: 'side-by-side' | 'stacked';
|
||||||
|
setLayout: (layout: 'side-by-side' | 'stacked') => void;
|
||||||
|
toggleLayout: () => void;
|
||||||
|
baseScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
comparisonScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
isScrollLinked: boolean;
|
||||||
|
setIsScrollLinked: (value: boolean) => void;
|
||||||
|
captureScrollLinkDelta: () => void;
|
||||||
|
clearScrollLinkDelta: () => void;
|
||||||
|
isPanMode: boolean;
|
||||||
|
setIsPanMode: (value: boolean) => void;
|
||||||
|
baseZoom: number;
|
||||||
|
setBaseZoom: (value: number) => void;
|
||||||
|
comparisonZoom: number;
|
||||||
|
setComparisonZoom: (value: number) => void;
|
||||||
|
basePan: PanState;
|
||||||
|
comparisonPan: PanState;
|
||||||
|
setPanToTopLeft: (pane: ComparePane) => void;
|
||||||
|
centerPanForZoom: (pane: ComparePane, zoom: number) => void;
|
||||||
|
clampPanForZoom: (pane: ComparePane, zoom: number) => void;
|
||||||
|
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
|
||||||
|
beginPan: (pane: ComparePane, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
continuePan: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
endPan: () => void;
|
||||||
|
handleWheelZoom: (pane: ComparePane, event: React.WheelEvent<HTMLDivElement>) => void;
|
||||||
|
handleWheelOverscroll: (pane: ComparePane, event: React.WheelEvent<HTMLDivElement>) => void;
|
||||||
|
onTouchStart: (pane: ComparePane, event: React.TouchEvent<HTMLDivElement>) => void;
|
||||||
|
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
|
||||||
|
onTouchEnd: () => void;
|
||||||
|
zoomLimits: { min: number; max: number; step: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagePreview {
|
||||||
|
pageNumber: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordHighlightEntry {
|
||||||
|
rect: TokenBoundingBox;
|
||||||
|
metaIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed legacy upload section types; upload flow now uses the standard active files workbench
|
||||||
|
|
||||||
|
export interface CompareWorkbenchData {
|
||||||
|
result: CompareResultData | null;
|
||||||
|
baseFileId: FileId | null;
|
||||||
|
comparisonFileId: FileId | null;
|
||||||
|
onSelectBase?: (fileId: FileId | null) => void;
|
||||||
|
onSelectComparison?: (fileId: FileId | null) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
baseLocalFile?: StirlingFile | null;
|
||||||
|
comparisonLocalFile?: StirlingFile | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompareChangeOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
pageNumber: number;
|
||||||
|
}
|
||||||
@@ -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) {
|
if (!exportActions) {
|
||||||
console.error('No export actions available');
|
console.error('No export actions available');
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CompareDiffToken,
|
||||||
|
CompareWorkerRequest,
|
||||||
|
CompareWorkerResponse,
|
||||||
|
} from '@app/types/compare';
|
||||||
|
|
||||||
|
declare const self: DedicatedWorkerGlobalScope;
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
batchSize: 5000,
|
||||||
|
complexThreshold: 25000,
|
||||||
|
maxWordThreshold: 60000,
|
||||||
|
// Early stop configuration
|
||||||
|
earlyStopEnabled: true,
|
||||||
|
// Jaccard thresholds for quick prefilter (unigram/bigram)
|
||||||
|
minJaccardUnigram: 0.005,
|
||||||
|
minJaccardBigram: 0.003,
|
||||||
|
// Only consider early stop when docs are reasonably large
|
||||||
|
minTokensForEarlyStop: 20000,
|
||||||
|
// Sampling cap for similarity estimation
|
||||||
|
sampleLimit: 50000,
|
||||||
|
// Runtime stop-loss during chunked diff
|
||||||
|
runtimeMaxProcessedTokens: 150000,
|
||||||
|
runtimeMinUnchangedRatio: 0.001,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMatrix = (words1: string[], words2: string[]) => {
|
||||||
|
const rows = words1.length + 1;
|
||||||
|
const cols = words2.length + 1;
|
||||||
|
const matrix: number[][] = new Array(rows);
|
||||||
|
|
||||||
|
for (let i = 0; i < rows; i += 1) {
|
||||||
|
matrix[i] = new Array(cols).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= words1.length; i += 1) {
|
||||||
|
for (let j = 1; j <= words2.length; j += 1) {
|
||||||
|
matrix[i][j] =
|
||||||
|
words1[i - 1] === words2[j - 1]
|
||||||
|
? matrix[i - 1][j - 1] + 1
|
||||||
|
: Math.max(matrix[i][j - 1], matrix[i - 1][j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backtrack = (matrix: number[][], words1: string[], words2: string[]): CompareDiffToken[] => {
|
||||||
|
const tokens: CompareDiffToken[] = [];
|
||||||
|
let i = words1.length;
|
||||||
|
let j = words2.length;
|
||||||
|
|
||||||
|
while (i > 0 || j > 0) {
|
||||||
|
if (i > 0 && j > 0 && words1[i - 1] === words2[j - 1]) {
|
||||||
|
tokens.unshift({ type: 'unchanged', text: words1[i - 1] });
|
||||||
|
i -= 1;
|
||||||
|
j -= 1;
|
||||||
|
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
|
||||||
|
tokens.unshift({ type: 'added', text: words2[j - 1] });
|
||||||
|
j -= 1;
|
||||||
|
} else if (i > 0) {
|
||||||
|
tokens.unshift({ type: 'removed', text: words1[i - 1] });
|
||||||
|
i -= 1;
|
||||||
|
} else {
|
||||||
|
j -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const diff = (words1: string[], words2: string[]): CompareDiffToken[] => {
|
||||||
|
if (words1.length === 0 && words2.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matrix = buildMatrix(words1, words2);
|
||||||
|
return backtrack(matrix, words1, words2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const countBaseTokens = (segment: CompareDiffToken[]) =>
|
||||||
|
segment.reduce((acc, token) => acc + (token.type !== 'added' ? 1 : 0), 0);
|
||||||
|
|
||||||
|
const countComparisonTokens = (segment: CompareDiffToken[]) =>
|
||||||
|
segment.reduce((acc, token) => acc + (token.type !== 'removed' ? 1 : 0), 0);
|
||||||
|
|
||||||
|
const findLastUnchangedIndex = (segment: CompareDiffToken[]) => {
|
||||||
|
for (let i = segment.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (segment[i].type === 'unchanged') {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunkedDiff = (
|
||||||
|
words1: string[],
|
||||||
|
words2: string[],
|
||||||
|
chunkSize: number,
|
||||||
|
emit: (tokens: CompareDiffToken[]) => void,
|
||||||
|
runtimeStop?: { maxProcessedTokens: number; minUnchangedRatio: number }
|
||||||
|
) => {
|
||||||
|
if (words1.length === 0 && words2.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseChunkSize = Math.max(1, chunkSize);
|
||||||
|
let dynamicChunkSize = baseChunkSize;
|
||||||
|
const baseMaxWindow = Math.max(baseChunkSize * 6, baseChunkSize + 512);
|
||||||
|
let dynamicMaxWindow = baseMaxWindow;
|
||||||
|
let dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1));
|
||||||
|
let dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5));
|
||||||
|
let stallIterations = 0;
|
||||||
|
|
||||||
|
const increaseChunkSizes = () => {
|
||||||
|
const maxChunkSize = baseChunkSize * 8;
|
||||||
|
if (dynamicChunkSize >= maxChunkSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextChunk = Math.min(
|
||||||
|
maxChunkSize,
|
||||||
|
Math.max(dynamicChunkSize + dynamicStep, Math.floor(dynamicChunkSize * 1.5))
|
||||||
|
);
|
||||||
|
if (nextChunk === dynamicChunkSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dynamicChunkSize = nextChunk;
|
||||||
|
dynamicMaxWindow = Math.max(dynamicMaxWindow, Math.max(dynamicChunkSize * 6, dynamicChunkSize + 512));
|
||||||
|
dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1));
|
||||||
|
dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5));
|
||||||
|
};
|
||||||
|
|
||||||
|
let index1 = 0;
|
||||||
|
let index2 = 0;
|
||||||
|
let buffer1: string[] = [];
|
||||||
|
let buffer2: string[] = [];
|
||||||
|
let totalProcessedBase = 0;
|
||||||
|
let totalProcessedComp = 0;
|
||||||
|
let totalUnchanged = 0;
|
||||||
|
|
||||||
|
const countUnchanged = (segment: CompareDiffToken[]) =>
|
||||||
|
segment.reduce((acc, token) => acc + (token.type === 'unchanged' ? 1 : 0), 0);
|
||||||
|
|
||||||
|
const flushRemainder = () => {
|
||||||
|
if (buffer1.length === 0 && buffer2.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const finalTokens = diff(buffer1, buffer2);
|
||||||
|
if (finalTokens.length > 0) {
|
||||||
|
emit(finalTokens);
|
||||||
|
}
|
||||||
|
buffer1 = [];
|
||||||
|
buffer2 = [];
|
||||||
|
index1 = words1.length;
|
||||||
|
index2 = words2.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
while (
|
||||||
|
index1 < words1.length ||
|
||||||
|
index2 < words2.length ||
|
||||||
|
buffer1.length > 0 ||
|
||||||
|
buffer2.length > 0
|
||||||
|
) {
|
||||||
|
const remaining1 = Math.max(0, words1.length - index1);
|
||||||
|
const remaining2 = Math.max(0, words2.length - index2);
|
||||||
|
|
||||||
|
let windowSize = Math.max(dynamicChunkSize, buffer1.length, buffer2.length);
|
||||||
|
let window1: string[] = [];
|
||||||
|
let window2: string[] = [];
|
||||||
|
let chunkTokens: CompareDiffToken[] = [];
|
||||||
|
let reachedEnd = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const take1 = Math.min(Math.max(0, windowSize - buffer1.length), remaining1);
|
||||||
|
const take2 = Math.min(Math.max(0, windowSize - buffer2.length), remaining2);
|
||||||
|
|
||||||
|
const slice1 = take1 > 0 ? words1.slice(index1, index1 + take1) : [];
|
||||||
|
const slice2 = take2 > 0 ? words2.slice(index2, index2 + take2) : [];
|
||||||
|
|
||||||
|
window1 = buffer1.length > 0 ? [...buffer1, ...slice1] : slice1;
|
||||||
|
window2 = buffer2.length > 0 ? [...buffer2, ...slice2] : slice2;
|
||||||
|
|
||||||
|
if (window1.length === 0 && window2.length === 0) {
|
||||||
|
flushRemainder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkTokens = diff(window1, window2);
|
||||||
|
const lastStableIndex = findLastUnchangedIndex(chunkTokens);
|
||||||
|
|
||||||
|
reachedEnd =
|
||||||
|
index1 + take1 >= words1.length &&
|
||||||
|
index2 + take2 >= words2.length;
|
||||||
|
|
||||||
|
const windowTooLarge =
|
||||||
|
window1.length >= dynamicMaxWindow ||
|
||||||
|
window2.length >= dynamicMaxWindow;
|
||||||
|
|
||||||
|
if (lastStableIndex >= 0 || reachedEnd || windowTooLarge) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGrow1 = take1 < remaining1;
|
||||||
|
const canGrow2 = take2 < remaining2;
|
||||||
|
|
||||||
|
if (!canGrow1 && !canGrow2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowSize = Math.min(
|
||||||
|
dynamicMaxWindow,
|
||||||
|
windowSize + dynamicStep
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkTokens.length === 0) {
|
||||||
|
if (reachedEnd) {
|
||||||
|
flushRemainder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
windowSize = Math.min(windowSize + dynamicStep, dynamicMaxWindow);
|
||||||
|
stallIterations += 1;
|
||||||
|
if (stallIterations >= 3) {
|
||||||
|
increaseChunkSizes();
|
||||||
|
stallIterations = 0;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let commitIndex = reachedEnd ? chunkTokens.length - 1 : findLastUnchangedIndex(chunkTokens);
|
||||||
|
if (commitIndex < 0) {
|
||||||
|
commitIndex = reachedEnd
|
||||||
|
? chunkTokens.length - 1
|
||||||
|
: Math.min(chunkTokens.length - 1, dynamicMinCommit - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitTokens = commitIndex >= 0 ? chunkTokens.slice(0, commitIndex + 1) : [];
|
||||||
|
const baseConsumed = countBaseTokens(commitTokens);
|
||||||
|
const comparisonConsumed = countComparisonTokens(commitTokens);
|
||||||
|
|
||||||
|
if (commitTokens.length > 0) {
|
||||||
|
emit(commitTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumedFromNew1 = Math.max(0, baseConsumed - buffer1.length);
|
||||||
|
const consumedFromNew2 = Math.max(0, comparisonConsumed - buffer2.length);
|
||||||
|
|
||||||
|
index1 += consumedFromNew1;
|
||||||
|
index2 += consumedFromNew2;
|
||||||
|
|
||||||
|
buffer1 = window1.slice(baseConsumed);
|
||||||
|
buffer2 = window2.slice(comparisonConsumed);
|
||||||
|
// Update runtime counters and early stop if necessary
|
||||||
|
totalProcessedBase += baseConsumed;
|
||||||
|
totalProcessedComp += comparisonConsumed;
|
||||||
|
totalUnchanged += countUnchanged(commitTokens);
|
||||||
|
|
||||||
|
if (runtimeStop) {
|
||||||
|
const processedTotal = totalProcessedBase + totalProcessedComp;
|
||||||
|
if (processedTotal >= runtimeStop.maxProcessedTokens) {
|
||||||
|
const unchangedRatio = totalUnchanged / Math.max(1, processedTotal);
|
||||||
|
if (unchangedRatio < runtimeStop.minUnchangedRatio) {
|
||||||
|
// Signal early termination for extreme dissimilarity
|
||||||
|
const err = new Error('EARLY_STOP_TOO_DISSIMILAR');
|
||||||
|
(err as Error & { __earlyStop?: boolean }).__earlyStop = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reachedEnd) {
|
||||||
|
flushRemainder();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commitTokens.length < dynamicMinCommit) {
|
||||||
|
stallIterations += 1;
|
||||||
|
} else {
|
||||||
|
stallIterations = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commitTokens.length === 0 && buffer1.length + buffer2.length > 0) {
|
||||||
|
if (buffer1.length > 0 && index1 < words1.length) {
|
||||||
|
buffer1 = buffer1.slice(1);
|
||||||
|
index1 += 1;
|
||||||
|
} else if (buffer2.length > 0 && index2 < words2.length) {
|
||||||
|
buffer2 = buffer2.slice(1);
|
||||||
|
index2 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stallIterations >= 3) {
|
||||||
|
increaseChunkSizes();
|
||||||
|
stallIterations = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushRemainder();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fast similarity estimation using sampled unigrams and bigrams with Jaccard
|
||||||
|
const buildSampledSet = (tokens: string[], sampleLimit: number, ngram: 1 | 2): Set<string> => {
|
||||||
|
const result = new Set<string>();
|
||||||
|
if (tokens.length === 0) return result;
|
||||||
|
const stride = Math.max(1, Math.ceil(tokens.length / sampleLimit));
|
||||||
|
if (ngram === 1) {
|
||||||
|
for (let i = 0; i < tokens.length; i += stride) {
|
||||||
|
const t = tokens[i];
|
||||||
|
if (t) result.add(t);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// ngram === 2
|
||||||
|
for (let i = 0; i + 1 < tokens.length; i += stride) {
|
||||||
|
const a = tokens[i];
|
||||||
|
const b = tokens[i + 1];
|
||||||
|
if (a && b) result.add(`${a}|${b}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jaccard = (a: Set<string>, b: Set<string>): number => {
|
||||||
|
if (a.size === 0 && b.size === 0) return 1;
|
||||||
|
if (a.size === 0 || b.size === 0) return 0;
|
||||||
|
let intersection = 0;
|
||||||
|
const smaller = a.size <= b.size ? a : b;
|
||||||
|
const larger = a.size <= b.size ? b : a;
|
||||||
|
for (const v of smaller) {
|
||||||
|
if (larger.has(v)) intersection += 1;
|
||||||
|
}
|
||||||
|
const union = a.size + b.size - intersection;
|
||||||
|
return union > 0 ? intersection / union : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<CompareWorkerRequest>) => {
|
||||||
|
const { data } = event;
|
||||||
|
if (!data || data.type !== 'compare') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { baseTokens, comparisonTokens, warnings, settings } = data.payload;
|
||||||
|
const {
|
||||||
|
batchSize = DEFAULT_SETTINGS.batchSize,
|
||||||
|
complexThreshold = DEFAULT_SETTINGS.complexThreshold,
|
||||||
|
maxWordThreshold = DEFAULT_SETTINGS.maxWordThreshold,
|
||||||
|
earlyStopEnabled = DEFAULT_SETTINGS.earlyStopEnabled,
|
||||||
|
minJaccardUnigram = DEFAULT_SETTINGS.minJaccardUnigram,
|
||||||
|
minJaccardBigram = DEFAULT_SETTINGS.minJaccardBigram,
|
||||||
|
minTokensForEarlyStop = DEFAULT_SETTINGS.minTokensForEarlyStop,
|
||||||
|
sampleLimit = DEFAULT_SETTINGS.sampleLimit,
|
||||||
|
runtimeMaxProcessedTokens = DEFAULT_SETTINGS.runtimeMaxProcessedTokens,
|
||||||
|
runtimeMinUnchangedRatio = DEFAULT_SETTINGS.runtimeMinUnchangedRatio,
|
||||||
|
} = settings ?? {};
|
||||||
|
|
||||||
|
if (!baseTokens || !comparisonTokens || baseTokens.length === 0 || comparisonTokens.length === 0) {
|
||||||
|
const response: CompareWorkerResponse = {
|
||||||
|
type: 'error',
|
||||||
|
message: warnings.emptyTextMessage ?? 'One or both texts are empty.',
|
||||||
|
code: 'EMPTY_TEXT',
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseTokens.length > maxWordThreshold || comparisonTokens.length > maxWordThreshold) {
|
||||||
|
// For compare tool, do not fail hard; warn and continue with chunked diff
|
||||||
|
const response: CompareWorkerResponse = {
|
||||||
|
type: 'warning',
|
||||||
|
message: warnings.tooLargeMessage ?? 'Documents are too large to compare.',
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isComplex = baseTokens.length > complexThreshold || comparisonTokens.length > complexThreshold;
|
||||||
|
|
||||||
|
if (isComplex && warnings.complexMessage) {
|
||||||
|
const warningResponse: CompareWorkerResponse = {
|
||||||
|
type: 'warning',
|
||||||
|
message: warnings.complexMessage,
|
||||||
|
};
|
||||||
|
self.postMessage(warningResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick prefilter to avoid heavy diff on extremely dissimilar large docs
|
||||||
|
if (earlyStopEnabled && Math.min(baseTokens.length, comparisonTokens.length) >= minTokensForEarlyStop) {
|
||||||
|
const set1u = buildSampledSet(baseTokens, sampleLimit, 1);
|
||||||
|
const set2u = buildSampledSet(comparisonTokens, sampleLimit, 1);
|
||||||
|
const jUni = jaccard(set1u, set2u);
|
||||||
|
const set1b = buildSampledSet(baseTokens, sampleLimit, 2);
|
||||||
|
const set2b = buildSampledSet(comparisonTokens, sampleLimit, 2);
|
||||||
|
const jBi = jaccard(set1b, set2b);
|
||||||
|
if (jUni < minJaccardUnigram && jBi < minJaccardBigram) {
|
||||||
|
const response: CompareWorkerResponse = {
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
warnings.tooDissimilarMessage ??
|
||||||
|
'These documents appear highly dissimilar. Comparison was stopped to save time.',
|
||||||
|
code: 'TOO_DISSIMILAR',
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
chunkedDiff(
|
||||||
|
baseTokens,
|
||||||
|
comparisonTokens,
|
||||||
|
batchSize,
|
||||||
|
(tokens) => {
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response: CompareWorkerResponse = {
|
||||||
|
type: 'chunk',
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
},
|
||||||
|
{ maxProcessedTokens: runtimeMaxProcessedTokens, minUnchangedRatio: runtimeMinUnchangedRatio }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error & { __earlyStop?: boolean };
|
||||||
|
if (error && (error.__earlyStop || error.message === 'EARLY_STOP_TOO_DISSIMILAR')) {
|
||||||
|
const response: CompareWorkerResponse = {
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
warnings.tooDissimilarMessage ??
|
||||||
|
'These documents appear highly dissimilar. Comparison was stopped to save time.',
|
||||||
|
code: 'TOO_DISSIMILAR',
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const durationMs = performance.now() - start;
|
||||||
|
|
||||||
|
const response: CompareWorkerResponse = {
|
||||||
|
type: 'success',
|
||||||
|
stats: {
|
||||||
|
baseWordCount: baseTokens.length,
|
||||||
|
comparisonWordCount: comparisonTokens.length,
|
||||||
|
durationMs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
self.postMessage(response);
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useMediaQuery } from '@mantine/hooks';
|
|
||||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||||
import { useAuth } from '@app/auth/UseSession';
|
import { useAuth } from '@app/auth/UseSession';
|
||||||
|
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||||
|
|
||||||
export function useShouldShowWelcomeModal(): boolean {
|
export function useShouldShowWelcomeModal(): boolean {
|
||||||
const { preferences } = usePreferences();
|
const { preferences } = usePreferences();
|
||||||
const { session, loading } = useAuth();
|
const { session, loading } = useAuth();
|
||||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Only show welcome modal if user is authenticated (session exists)
|
// Only show welcome modal if user is authenticated (session exists)
|
||||||
// This prevents the modal from showing on login screens when security is enabled
|
// This prevents the modal from showing on login screens when security is enabled
|
||||||
|
|||||||
Reference in New Issue
Block a user