mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-03 20:04:28 +01: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 <jbrunton96@gmail.com>
This commit is contained in:
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-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
|
||||
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
|
||||
/* Clear button disabled text color (default/light) */
|
||||
--spdf-clear-disabled-text: var(--md-sys-color-primary);
|
||||
/* Icon fill */
|
||||
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
|
||||
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
|
||||
@ -25,6 +27,12 @@
|
||||
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
.dark-theme {
|
||||
/* In dark mode, use a neutral grey for disabled Clear button text */
|
||||
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
|
||||
}
|
||||
|
||||
.fill {
|
||||
font-variation-settings: var(--md-sys-icon-fill-1);
|
||||
}
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"addPageNumbers": {
|
||||
"fontSize": "Font Size",
|
||||
"fontName": "Font Name",
|
||||
@ -2157,15 +2158,99 @@
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
"title": "Compare",
|
||||
"header": "Compare PDFs",
|
||||
"highlightColor": {
|
||||
"1": "Highlight Colour 1:",
|
||||
"2": "Highlight Colour 2:"
|
||||
"clearSelected": "Clear selected",
|
||||
"clear": {
|
||||
"confirmTitle": "Clear selected PDFs?",
|
||||
"confirmBody": "This will close the current comparison and take you back to Active Files.",
|
||||
"confirm": "Clear and return"
|
||||
},
|
||||
"document": {
|
||||
"1": "Document 1",
|
||||
"2": "Document 2"
|
||||
"review": {
|
||||
"title": "Comparison Result",
|
||||
"actionsHint": "Review the comparison, switch document roles, or export the summary.",
|
||||
"switchOrder": "Switch order",
|
||||
"exportSummary": "Export summary"
|
||||
},
|
||||
"submit": "Compare",
|
||||
"base": {
|
||||
"label": "Original document",
|
||||
"placeholder": "Select the original PDF"
|
||||
},
|
||||
"comparison": {
|
||||
"label": "Edited document",
|
||||
"placeholder": "Select the edited PDF"
|
||||
},
|
||||
"addFilesHint": "Add PDFs in the Files step to enable selection.",
|
||||
"noFiles": "No PDFs available yet",
|
||||
"pages": "Pages",
|
||||
"selection": {
|
||||
"originalEditedTitle": "Select Original and Edited PDFs"
|
||||
},
|
||||
"original": { "label": "Original PDF" },
|
||||
"edited": { "label": "Edited PDF" },
|
||||
"swap": {
|
||||
"confirmTitle": "Re-run comparison?",
|
||||
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
|
||||
"confirm": "Swap and Re-run"
|
||||
},
|
||||
"cta": "Compare",
|
||||
"loading": "Comparing...",
|
||||
|
||||
"summary": {
|
||||
"baseHeading": "Original document",
|
||||
"comparisonHeading": "Edited document",
|
||||
"pageLabel": "Page"
|
||||
},
|
||||
"rendering": {
|
||||
"pageNotReadyTitle": "Page not rendered yet",
|
||||
"pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.",
|
||||
"rendering": "rendering",
|
||||
"inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete",
|
||||
"pagesRendered": "pages rendered",
|
||||
"complete": "Page rendering complete"
|
||||
},
|
||||
"dropdown": {
|
||||
"deletionsLabel": "Deletions",
|
||||
"additionsLabel": "Additions",
|
||||
"deletions": "Deletions ({{count}})",
|
||||
"additions": "Additions ({{count}})",
|
||||
"searchPlaceholder": "Search changes...",
|
||||
"noResults": "No changes found"
|
||||
},
|
||||
"actions": {
|
||||
"stackVertically": "Stack vertically",
|
||||
"placeSideBySide": "Place side by side",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomIn": "Zoom in",
|
||||
"resetView": "Reset view",
|
||||
"unlinkScrollPan": "Unlink scroll and pan",
|
||||
"linkScrollPan": "Link scroll and pan",
|
||||
"unlinkScroll": "Unlink scroll",
|
||||
"linkScroll": "Link scroll"
|
||||
},
|
||||
"toasts": {
|
||||
"unlinkedTitle": "Independent scroll & pan enabled",
|
||||
"unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
|
||||
},
|
||||
"error": {
|
||||
"selectRequired": "Select a original and edited document.",
|
||||
"filesMissing": "Unable to locate the selected files. Please re-select them.",
|
||||
"generic": "Unable to compare these files."
|
||||
},
|
||||
"status": {
|
||||
"extracting": "Extracting text...",
|
||||
"processing": "Analysing differences...",
|
||||
"complete": "Comparison ready"
|
||||
},
|
||||
"longJob": {
|
||||
"title": "Large comparison in progress",
|
||||
"body": "These PDFs together exceed 2,000 pages. Processing can take several minutes."
|
||||
},
|
||||
"slowOperation": {
|
||||
"title": "Still working…",
|
||||
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
|
||||
"cancel": "Cancel comparison"
|
||||
},
|
||||
|
||||
"newLine": "new-line",
|
||||
"complex": {
|
||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||
},
|
||||
@ -2178,6 +2263,16 @@
|
||||
"text": {
|
||||
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
|
||||
}
|
||||
},
|
||||
"too": {
|
||||
"dissimilar": {
|
||||
"message": "These documents appear highly dissimilar. Comparison was stopped to save time."
|
||||
}
|
||||
},
|
||||
"earlyDissimilarity": {
|
||||
"title": "These PDFs look highly different",
|
||||
"body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.",
|
||||
"stopButton": "Stop comparison"
|
||||
}
|
||||
},
|
||||
"certSign": {
|
||||
|
||||
@ -2443,15 +2443,99 @@
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
"title": "Compare",
|
||||
"header": "Compare PDFs",
|
||||
"highlightColor": {
|
||||
"1": "Highlight Color 1:",
|
||||
"2": "Highlight Color 2:"
|
||||
"clearSelected": "Clear selected",
|
||||
"clear": {
|
||||
"confirmTitle": "Clear selected PDFs?",
|
||||
"confirmBody": "This will close the current comparison and take you back to Active Files.",
|
||||
"confirm": "Clear and return"
|
||||
},
|
||||
"document": {
|
||||
"1": "Document 1",
|
||||
"2": "Document 2"
|
||||
"review": {
|
||||
"title": "Comparison Result",
|
||||
"actionsHint": "Review the comparison, switch document roles, or export the summary.",
|
||||
"switchOrder": "Switch order",
|
||||
"exportSummary": "Export summary"
|
||||
},
|
||||
"submit": "Compare",
|
||||
"base": {
|
||||
"label": "Original document",
|
||||
"placeholder": "Select the original PDF"
|
||||
},
|
||||
"comparison": {
|
||||
"label": "Edited document",
|
||||
"placeholder": "Select the edited PDF"
|
||||
},
|
||||
"addFilesHint": "Add PDFs in the Files step to enable selection.",
|
||||
"noFiles": "No PDFs available yet",
|
||||
"pages": "Pages",
|
||||
"selection": {
|
||||
"originalEditedTitle": "Select Original and Edited PDFs"
|
||||
},
|
||||
"original": { "label": "Original PDF" },
|
||||
"edited": { "label": "Edited PDF" },
|
||||
"swap": {
|
||||
"confirmTitle": "Re-run comparison?",
|
||||
"confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
|
||||
"confirm": "Swap and Re-run"
|
||||
},
|
||||
"cta": "Compare",
|
||||
"loading": "Comparing...",
|
||||
|
||||
"summary": {
|
||||
"baseHeading": "Original document",
|
||||
"comparisonHeading": "Edited document",
|
||||
"pageLabel": "Page"
|
||||
},
|
||||
"rendering": {
|
||||
"pageNotReadyTitle": "Page not rendered yet",
|
||||
"pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.",
|
||||
"rendering": "rendering",
|
||||
"inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete",
|
||||
"pagesRendered": "pages rendered",
|
||||
"complete": "Page rendering complete"
|
||||
},
|
||||
"dropdown": {
|
||||
"deletionsLabel": "Deletions",
|
||||
"additionsLabel": "Additions",
|
||||
"deletions": "Deletions ({{count}})",
|
||||
"additions": "Additions ({{count}})",
|
||||
"searchPlaceholder": "Search changes...",
|
||||
"noResults": "No changes found"
|
||||
},
|
||||
"actions": {
|
||||
"stackVertically": "Stack vertically",
|
||||
"placeSideBySide": "Place side by side",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomIn": "Zoom in",
|
||||
"resetView": "Reset view",
|
||||
"unlinkScrollPan": "Unlink scroll and pan",
|
||||
"linkScrollPan": "Link scroll and pan",
|
||||
"unlinkScroll": "Unlink scroll",
|
||||
"linkScroll": "Link scroll"
|
||||
},
|
||||
"toasts": {
|
||||
"unlinkedTitle": "Independent scroll & pan enabled",
|
||||
"unlinkedBody": "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane."
|
||||
},
|
||||
"error": {
|
||||
"selectRequired": "Select a original and edited document.",
|
||||
"filesMissing": "Unable to locate the selected files. Please re-select them.",
|
||||
"generic": "Unable to compare these files."
|
||||
},
|
||||
"status": {
|
||||
"extracting": "Extracting text...",
|
||||
"processing": "Analysing differences...",
|
||||
"complete": "Comparison ready"
|
||||
},
|
||||
"longJob": {
|
||||
"title": "Large comparison in progress",
|
||||
"body": "These PDFs together exceed 2,000 pages. Processing can take several minutes."
|
||||
},
|
||||
"slowOperation": {
|
||||
"title": "Still working…",
|
||||
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
|
||||
"cancel": "Cancel comparison"
|
||||
},
|
||||
|
||||
"newLine": "new-line",
|
||||
"complex": {
|
||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||
},
|
||||
@ -2464,6 +2548,16 @@
|
||||
"text": {
|
||||
"message": "One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison."
|
||||
}
|
||||
},
|
||||
"too": {
|
||||
"dissimilar": {
|
||||
"message": "These documents appear highly dissimilar. Comparison was stopped to save time."
|
||||
}
|
||||
},
|
||||
"earlyDissimilarity": {
|
||||
"title": "These PDFs look highly different",
|
||||
"body": "We're seeing very few similarities so far. You can stop the comparison if these aren't related documents.",
|
||||
"stopButton": "Stop comparison"
|
||||
}
|
||||
},
|
||||
"certSign": {
|
||||
@ -3115,7 +3209,7 @@
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"baseFile": {
|
||||
"label": "Select Base PDF File"
|
||||
"label": "Select Original PDF File"
|
||||
},
|
||||
"overlayFiles": {
|
||||
"label": "Select Overlay PDF Files",
|
||||
|
||||
@ -2986,7 +2986,7 @@
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"baseFile": {
|
||||
"label": "Select Base PDF File"
|
||||
"label": "Select Original PDF File"
|
||||
},
|
||||
"overlayFiles": {
|
||||
"label": "Select Overlay PDF Files",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
@ -64,7 +64,7 @@ const FileEditorThumbnail = ({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
|
||||
@ -81,6 +81,7 @@ export default function Workbench() {
|
||||
|
||||
switch (currentView) {
|
||||
case "fileEditor":
|
||||
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
@ -98,6 +99,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
case "viewer":
|
||||
|
||||
return (
|
||||
<Viewer
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
@ -110,6 +112,7 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
case "pageEditor":
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageEditor
|
||||
@ -143,6 +146,8 @@ export default function Workbench() {
|
||||
default:
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
|
||||
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
@ -154,7 +159,7 @@ export default function Workbench() {
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
className="flex-1 h-full min-w-0 relative flex flex-col"
|
||||
data-tour="workbench"
|
||||
style={
|
||||
isRainbowMode
|
||||
@ -182,10 +187,11 @@ export default function Workbench() {
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
|
||||
className={`flex-1 min-h-0 relative z-10 ${currentView === 'viewer' || !isBaseWorkbench(currentView) ? '' : styles.workbenchScrollable}`}
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined,
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Text, Checkbox } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
@ -69,7 +69,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
|
||||
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import '@app/components/shared/AppConfigModal.css';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
@ -15,10 +15,10 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const [active, setActive] = useState<NavKey>('general');
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [active, setActive] = useState<NavKey>('general');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Extract section from URL path (e.g., /settings/people -> people)
|
||||
|
||||
@ -65,6 +65,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const clickPendingRef = useRef(false);
|
||||
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
// Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target
|
||||
const isDomNode = (value: unknown): value is Node =>
|
||||
typeof Node !== 'undefined' && value instanceof Node;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (openTimeoutRef.current) {
|
||||
clearTimeout(openTimeoutRef.current);
|
||||
@ -103,9 +107,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
(e: MouseEvent) => {
|
||||
const tEl = tooltipRef.current;
|
||||
const trg = triggerRef.current;
|
||||
const target = e.target as Node | null;
|
||||
const insideTooltip = tEl && target && tEl.contains(target);
|
||||
const insideTrigger = trg && target && trg.contains(target);
|
||||
const target = e.target as unknown;
|
||||
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
|
||||
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
|
||||
|
||||
// If pinned: only close when clicking outside BOTH tooltip & trigger
|
||||
if (isPinned) {
|
||||
@ -172,7 +176,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const related = e.relatedTarget as Node | null;
|
||||
|
||||
// Moving into the tooltip → keep open
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onPointerLeave?.(e);
|
||||
return;
|
||||
}
|
||||
@ -236,7 +240,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
|
||||
(children.props as any)?.onBlur?.(e);
|
||||
return;
|
||||
}
|
||||
@ -258,7 +262,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
const handleTooltipPointerLeave = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
|
||||
if (!isPinned) setOpen(false);
|
||||
},
|
||||
[isPinned, setOpen]
|
||||
|
||||
@ -168,7 +168,8 @@ const TopControls = ({
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
|
||||
|
||||
<SegmentedControl
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
|
||||
@ -93,13 +93,10 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
progress,
|
||||
} as ToastInstance;
|
||||
|
||||
// Detect completion
|
||||
// Detect completion but do not auto-flip to success.
|
||||
// Callers (e.g., compare workbench) explicitly set alertType when done.
|
||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||
// On completion: finalize type as success unless explicitly provided otherwise
|
||||
next.justCompleted = false;
|
||||
if (!updates.alertType) {
|
||||
next.alertType = 'success';
|
||||
}
|
||||
next.justCompleted = true;
|
||||
}
|
||||
|
||||
return next;
|
||||
|
||||
@ -31,6 +31,13 @@
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-center {
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
|
||||
@ -8,6 +8,7 @@ const locationToClass: Record<ToastLocation, string> = {
|
||||
'top-right': 'toast-container--top-right',
|
||||
'bottom-left': 'toast-container--bottom-left',
|
||||
'bottom-right': 'toast-container--bottom-right',
|
||||
'bottom-center': 'toast-container--bottom-center',
|
||||
};
|
||||
|
||||
function getToastItemClass(t: ToastInstance): string {
|
||||
@ -44,7 +45,7 @@ export default function ToastRenderer() {
|
||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [], 'bottom-center': [] });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ToastOptions } from '@app/components/toast/types';
|
||||
import { ToastApi, ToastInstance, ToastOptions } from '@app/components/toast/types';
|
||||
import { useToast, ToastProvider } from '@app/components/toast/ToastContext';
|
||||
import ToastRenderer from '@app/components/toast/ToastRenderer';
|
||||
|
||||
@ -7,18 +7,26 @@ export { useToast, ToastProvider, ToastRenderer };
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
type ToastContextApi = ToastApi & { toasts: ToastInstance[] };
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
const subscribers: Array<(fn: ToastContextApi) => void> = [];
|
||||
let api: ToastContextApi | null = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
provide(instance: ToastContextApi) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
subscribers.splice(0).forEach(cb => cb(instance));
|
||||
},
|
||||
get(): ToastContextApi | null {
|
||||
return api;
|
||||
},
|
||||
onReady(cb: (readyApi: ToastContextApi) => void) {
|
||||
if (api) {
|
||||
cb(api);
|
||||
} else {
|
||||
subscribers.push(cb);
|
||||
}
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -58,4 +66,3 @@ export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'bottom-center';
|
||||
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||
|
||||
export interface ToastOptions {
|
||||
|
||||
@ -10,7 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import { ActionIcon, ScrollArea } from '@mantine/core';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FullscreenToolSurface from '@app/components/tools/FullscreenToolSurface';
|
||||
@ -26,7 +26,7 @@ export default function ToolPanel() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs;
|
||||
const isMobile = useMediaQuery('(max-width: 1024px)');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
leftPanelView,
|
||||
|
||||
@ -0,0 +1,296 @@
|
||||
import { Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useMemo, useRef, useEffect } from 'react';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
import type { TokenBoundingBox, CompareDocumentPaneProps } from '@app/types/compare';
|
||||
import { mergeConnectedRects, normalizeRotation, groupWordRects, computePageLayoutMetrics } from '@app/components/tools/compare/compare';
|
||||
import CompareNavigationDropdown from '@app/components/tools/compare/CompareNavigationDropdown';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
// utilities moved to compare.ts
|
||||
|
||||
const CompareDocumentPane = ({
|
||||
pane,
|
||||
layout,
|
||||
scrollRef,
|
||||
peerScrollRef,
|
||||
handleScrollSync,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPanMode,
|
||||
zoom,
|
||||
title,
|
||||
dropdownPlaceholder,
|
||||
changes,
|
||||
onNavigateChange,
|
||||
isLoading,
|
||||
processingMessage,
|
||||
pages,
|
||||
pairedPages,
|
||||
wordHighlightMap,
|
||||
metaIndexToGroupId,
|
||||
documentLabel,
|
||||
pageLabel,
|
||||
altLabel,
|
||||
onVisiblePageChange,
|
||||
}: CompareDocumentPaneProps) => {
|
||||
const isMobileViewport = useIsMobile();
|
||||
const pairedPageMap = useMemo(() => {
|
||||
const map = new Map<number, PagePreview>();
|
||||
pairedPages.forEach((item) => {
|
||||
map.set(item.pageNumber, item);
|
||||
});
|
||||
return map;
|
||||
}, [pairedPages]);
|
||||
|
||||
const HIGHLIGHT_BG_VAR = pane === 'base' ? 'var(--spdf-compare-removed-bg)' : 'var(--spdf-compare-added-bg)';
|
||||
const OFFSET_PIXELS = pane === 'base' ? 4 : 2;
|
||||
const cursorStyle = isPanMode && zoom > 1 ? 'grab' : 'auto';
|
||||
const pagePanRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||
const dragRef = useRef<{ active: boolean; page: number | null; startX: number; startY: number; startPanX: number; startPanY: number }>({ active: false, page: null, startX: 0, startY: 0, startPanX: 0, startPanY: 0 });
|
||||
|
||||
// Track which page images have finished loading to avoid flashing between states
|
||||
const imageLoadedRef = useRef<Map<number, boolean>>(new Map());
|
||||
const visiblePageRafRef = useRef<number | null>(null);
|
||||
const lastReportedVisiblePageRef = useRef<number | null>(null);
|
||||
const pageNodesRef = useRef<HTMLElement[] | null>(null);
|
||||
const groupedRectsByPage = useMemo(() => {
|
||||
const out = new Map<number, Map<string, TokenBoundingBox[]>>();
|
||||
for (const p of pages) {
|
||||
const rects = wordHighlightMap.get(p.pageNumber) ?? [];
|
||||
out.set(p.pageNumber, groupWordRects(rects, metaIndexToGroupId, pane));
|
||||
}
|
||||
return out;
|
||||
}, [pages, wordHighlightMap, metaIndexToGroupId, pane]);
|
||||
|
||||
// When zoom returns to 1 (reset), clear per-page pan state so content is centered again
|
||||
useEffect(() => {
|
||||
if (zoom <= 1) {
|
||||
pagePanRef.current.clear();
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
return (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||
<CompareNavigationDropdown
|
||||
changes={changes}
|
||||
placeholder={dropdownPlaceholder ?? null}
|
||||
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
|
||||
onNavigate={onNavigateChange}
|
||||
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(event) => {
|
||||
handleScrollSync(event.currentTarget, peerScrollRef.current);
|
||||
// Notify parent about the currently visible page (throttled via rAF)
|
||||
if (visiblePageRafRef.current != null) return;
|
||||
if (!onVisiblePageChange || pages.length === 0) return;
|
||||
visiblePageRafRef.current = requestAnimationFrame(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const mid = container.scrollTop + container.clientHeight * 0.5;
|
||||
let bestPage = pages[0]?.pageNumber ?? 1;
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
let nodes = pageNodesRef.current;
|
||||
if (!nodes || nodes.length !== pages.length) {
|
||||
nodes = Array.from(container.querySelectorAll('.compare-diff-page')) as HTMLElement[];
|
||||
pageNodesRef.current = nodes;
|
||||
}
|
||||
for (const el of nodes) {
|
||||
const top = el.offsetTop;
|
||||
const height = el.clientHeight || 1;
|
||||
const center = top + height / 2;
|
||||
const dist = Math.abs(center - mid);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
const attr = el.getAttribute('data-page-number');
|
||||
const pn = attr ? parseInt(attr, 10) : NaN;
|
||||
if (!Number.isNaN(pn)) bestPage = pn;
|
||||
}
|
||||
}
|
||||
if (typeof onVisiblePageChange === 'function' && bestPage !== lastReportedVisiblePageRef.current) {
|
||||
lastReportedVisiblePageRef.current = bestPage;
|
||||
onVisiblePageChange(pane, bestPage);
|
||||
}
|
||||
visiblePageRafRef.current = null;
|
||||
});
|
||||
}}
|
||||
onMouseDown={undefined}
|
||||
onMouseMove={undefined}
|
||||
onMouseUp={undefined}
|
||||
onMouseLeave={undefined}
|
||||
onWheel={(event) => { handleWheelZoom(pane, event); handleWheelOverscroll(pane, event); }}
|
||||
onTouchStart={(event) => onTouchStart(pane, event)}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
className="compare-pane__scroll"
|
||||
style={{ cursor: cursorStyle }}
|
||||
>
|
||||
<Stack gap={zoom <= 0.6 ? 2 : zoom <= 0.85 ? 'xs' : 'sm'} className="compare-pane__content">
|
||||
{isLoading && (
|
||||
<Group justify="center" gap="xs" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm">{processingMessage}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pages.map((page) => {
|
||||
const peerPage = pairedPageMap.get(page.pageNumber);
|
||||
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
||||
const metrics = computePageLayoutMetrics({
|
||||
page,
|
||||
peerPage: peerPage ?? null,
|
||||
layout,
|
||||
isMobileViewport,
|
||||
scrollRefWidth: scrollRef.current?.clientWidth ?? null,
|
||||
viewportWidth,
|
||||
zoom,
|
||||
offsetPixels: OFFSET_PIXELS,
|
||||
});
|
||||
|
||||
const { highlightOffset, containerWidth, containerHeight, innerScale } = metrics;
|
||||
|
||||
// Compute clamped pan for current zoom so content always touches edges when in bounds
|
||||
const storedPan = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
|
||||
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||
const maxPanX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||
const maxPanY = Math.max(0, contentHeight - Math.round(containerHeight));
|
||||
const clampedPanX = Math.max(0, Math.min(maxPanX, storedPan.x));
|
||||
const clampedPanY = Math.max(0, Math.min(maxPanY, storedPan.y));
|
||||
|
||||
const groupedRects = groupedRectsByPage.get(page.pageNumber) ?? new Map();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="compare-diff-page"
|
||||
data-page-number={page.pageNumber}
|
||||
style={{ minHeight: `${containerHeight}px` }}
|
||||
>
|
||||
<div
|
||||
className="compare-page-title"
|
||||
style={{ width: `${containerWidth}px`, marginLeft: 'auto', marginRight: 'auto' }}
|
||||
>
|
||||
<Text size="xs" fw={600} c="dimmed" ta="center">
|
||||
{documentLabel} · {pageLabel} {page.pageNumber}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className="compare-diff-page__canvas compare-diff-page__canvas--zoom"
|
||||
style={{ width: `${containerWidth}px`, height: `${containerHeight}px`, marginLeft: 'auto', marginRight: 'auto', overflow: 'hidden' }}
|
||||
onMouseDown={(e) => {
|
||||
if (!isPanMode || zoom <= 1) return;
|
||||
dragRef.current.active = true;
|
||||
dragRef.current.page = page.pageNumber;
|
||||
dragRef.current.startX = e.clientX;
|
||||
dragRef.current.startY = e.clientY;
|
||||
const curr = pagePanRef.current.get(page.pageNumber) || { x: 0, y: 0 };
|
||||
dragRef.current.startPanX = curr.x;
|
||||
dragRef.current.startPanY = curr.y;
|
||||
(e.currentTarget as HTMLElement).style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (!dragRef.current.active || dragRef.current.page !== page.pageNumber) return;
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
const dy = e.clientY - dragRef.current.startY;
|
||||
// Clamp panning based on the actual rendered content size.
|
||||
// The inner layer is width/height of the container, then scaled by innerScale.
|
||||
const contentWidth = Math.max(0, Math.round(containerWidth * innerScale));
|
||||
const contentHeight = Math.max(0, Math.round(containerHeight * innerScale));
|
||||
const maxX = Math.max(0, contentWidth - Math.round(containerWidth));
|
||||
const maxY = Math.max(0, contentHeight - Math.round(containerHeight));
|
||||
const candX = dragRef.current.startPanX - dx;
|
||||
const candY = dragRef.current.startPanY - dy;
|
||||
const next = { x: Math.max(0, Math.min(maxX, candX)), y: Math.max(0, Math.min(maxY, candY)) };
|
||||
pagePanRef.current.set(page.pageNumber, next);
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
if (dragRef.current.active) {
|
||||
dragRef.current.active = false;
|
||||
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (dragRef.current.active) {
|
||||
dragRef.current.active = false;
|
||||
(e.currentTarget as HTMLElement).style.cursor = cursorStyle;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`compare-diff-page__inner compare-diff-page__inner--${pane}`}
|
||||
style={{
|
||||
transform: `scale(${innerScale}) translate3d(${-((clampedPanX) / innerScale)}px, ${-((clampedPanY) / innerScale)}px, 0)`,
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{/* Image layer */}
|
||||
<img
|
||||
src={page.url ?? ''}
|
||||
alt={altLabel}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="compare-diff-page__image"
|
||||
onLoad={() => {
|
||||
if (!imageLoadedRef.current.get(page.pageNumber)) {
|
||||
imageLoadedRef.current.set(page.pageNumber, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Overlay loader until the page image is loaded */}
|
||||
{!((imageLoadedRef.current.get(page.pageNumber) ?? false)) && (
|
||||
<div className="compare-page-loader-overlay">
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
)}
|
||||
{[...groupedRects.entries()].flatMap(([id, rects]) =>
|
||||
mergeConnectedRects(rects).map((rect, index) => {
|
||||
const rotation = normalizeRotation(page.rotation);
|
||||
const verticalOffset = rotation === 180 ? -highlightOffset : highlightOffset;
|
||||
return (
|
||||
<span
|
||||
key={`${pane}-highlight-${page.pageNumber}-${id}-${index}`}
|
||||
data-change-id={id}
|
||||
className="compare-diff-highlight"
|
||||
style={{
|
||||
left: `${rect.left * 100}%`,
|
||||
top: `${(rect.top + verticalOffset) * 100}%`,
|
||||
width: `${rect.width * 100}%`,
|
||||
height: `${rect.height * 100}%`,
|
||||
backgroundColor: HIGHLIGHT_BG_VAR,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareDocumentPane;
|
||||
@ -0,0 +1,229 @@
|
||||
import { Combobox, ScrollArea, useCombobox } from '@mantine/core';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NavigationDropdownProps } from '@app/types/compare';
|
||||
|
||||
const CompareNavigationDropdown = ({
|
||||
changes,
|
||||
placeholder,
|
||||
className,
|
||||
onNavigate,
|
||||
renderedPageNumbers,
|
||||
}: NavigationDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const newLineLabel = t('compare.newLine', 'new-line');
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
// Cache scrollTop so we can restore on next open
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) scrollTopRef.current = viewport.scrollTop;
|
||||
setIsOpen(false);
|
||||
},
|
||||
onDropdownOpen: () => {
|
||||
setIsOpen(true);
|
||||
// Restore scrollTop after mount and rebuild offsets
|
||||
requestAnimationFrame(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) viewport.scrollTop = scrollTopRef.current;
|
||||
const headers = Array.from((viewportRef.current?.querySelectorAll('.compare-dropdown-group') ?? [])) as HTMLElement[];
|
||||
groupOffsetsRef.current = headers.map((el) => {
|
||||
const text = el.textContent || '';
|
||||
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
return { top: el.offsetTop, page };
|
||||
});
|
||||
// Update sticky label based on current scroll position
|
||||
handleScrollPos({ x: 0, y: scrollTopRef.current });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sanitize = (s: string) => {
|
||||
// Normalize and remove control/separator characters without regex ranges
|
||||
return s
|
||||
.normalize('NFKC')
|
||||
.split('')
|
||||
.map(char => {
|
||||
const code = char.charCodeAt(0);
|
||||
// Replace control chars (0-31, 127) and special separators with space
|
||||
if (code <= 31 || code === 127 || code === 0x2028 || code === 0x2029 || (code >= 0x200B && code <= 0x200F)) {
|
||||
return ' ';
|
||||
}
|
||||
return char;
|
||||
})
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const isMeaningful = (s: string) => {
|
||||
const t = sanitize(s);
|
||||
|
||||
// Build a unicode-aware regex if supported; otherwise fall back to a plain ASCII class.
|
||||
const rx =
|
||||
(() => {
|
||||
try {
|
||||
// Construct at runtime so old engines don’t fail parse-time
|
||||
return new RegExp('[\\p{L}\\p{N}\\p{P}\\p{S}]', 'u');
|
||||
} catch {
|
||||
// Fallback (no Unicode props): letters, digits, and common punctuation/symbols
|
||||
return /[A-Za-z0-9.,!?;:(){}"'`~@#$%^&*+=|<>/[\]]/;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!rx.test(t)) return false;
|
||||
return t.length > 0;
|
||||
};
|
||||
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [stickyPage, setStickyPage] = useState<number | null>(null);
|
||||
const groupOffsetsRef = useRef<Array<{ top: number; page: number }>>([]);
|
||||
const scrollTopRef = useRef(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const normalizedChanges = useMemo(() => {
|
||||
// Helper to strip localized new-line marker occurrences from labels
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const stripNewLine = (s: string) =>
|
||||
s
|
||||
.replace(new RegExp(`\\b${esc(newLineLabel)}\\b`, 'gi'), ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const cleaned = changes
|
||||
.map((c) => ({ value: c.value, label: stripNewLine(sanitize(c.label)), pageNumber: c.pageNumber }))
|
||||
.filter((c) => isMeaningful(c.label) && c.label.length > 0 && c.label.toLowerCase() !== newLineLabel.toLowerCase());
|
||||
const q = sanitize(query).toLowerCase();
|
||||
if (!q) return cleaned;
|
||||
return cleaned.filter((c) => c.label.toLowerCase().includes(q));
|
||||
}, [changes, query, newLineLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
// Build offsets for group headers whenever list changes while open
|
||||
if (!isOpen) return;
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const headers = Array.from(viewport.querySelectorAll('.compare-dropdown-group')) as HTMLElement[];
|
||||
groupOffsetsRef.current = headers.map((el) => {
|
||||
const text = el.textContent || '';
|
||||
const page = parseInt(text.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
return { top: el.offsetTop, page };
|
||||
});
|
||||
// Update sticky based on current scroll position
|
||||
handleScrollPos({ x: 0, y: viewport.scrollTop });
|
||||
}, [normalizedChanges, isOpen]);
|
||||
|
||||
const handleScrollPos = ({ y }: { x: number; y: number }) => {
|
||||
const offsets = groupOffsetsRef.current;
|
||||
if (offsets.length === 0) return;
|
||||
// Find the last header whose top is <= scroll, so the next header replaces it
|
||||
let low = 0;
|
||||
let high = offsets.length - 1;
|
||||
let idx = 0;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (offsets[mid].top <= y + 1) { // +1 to avoid jitter at exact boundary
|
||||
idx = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
const page = offsets[idx]?.page ?? offsets[0].page;
|
||||
if (page !== stickyPage) setStickyPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => {
|
||||
const pn = normalizedChanges.find((c) => c.value === value)?.pageNumber;
|
||||
onNavigate(value, pn);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
// Mantine Combobox does not accept controlled search props; handle via Combobox.Search directly
|
||||
>
|
||||
<Combobox.Target>
|
||||
<div
|
||||
className={['compare-changes-select', className].filter(Boolean).join(' ')}
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
>
|
||||
<span className="compare-changes-select__placeholder">{placeholder}</span>
|
||||
<Combobox.Chevron />
|
||||
</div>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown className="compare-changes-dropdown">
|
||||
{/* Header sits outside scroll so it stays fixed at top */}
|
||||
<div>
|
||||
<Combobox.Search
|
||||
placeholder={t('compare.dropdown.searchPlaceholder', 'Search changes...')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Lazy render the scrollable content only when open */}
|
||||
{isOpen && (
|
||||
<div className="compare-dropdown-scrollwrap">
|
||||
<ScrollArea.Autosize mah={300} viewportRef={viewportRef} onScrollPositionChange={handleScrollPos}>
|
||||
{stickyPage != null && (
|
||||
<div className="compare-dropdown-sticky" style={{ top: 0 }}>
|
||||
{t('compare.summary.pageLabel', 'Page')}{' '}{stickyPage}
|
||||
{renderedPageNumbers && !renderedPageNumbers.has(stickyPage) && (
|
||||
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Combobox.Options className="compare-dropdown-options">
|
||||
{normalizedChanges.length > 0 ? (
|
||||
(() => {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let lastPage: number | null = null;
|
||||
for (const item of normalizedChanges) {
|
||||
if (item.pageNumber && item.pageNumber !== lastPage) {
|
||||
lastPage = item.pageNumber;
|
||||
nodes.push(
|
||||
<div
|
||||
className={["compare-dropdown-group", stickyPage === lastPage ? "compare-dropdown-group--hidden" : ""].filter(Boolean).join(" ")}
|
||||
key={`group-${lastPage}`}
|
||||
>
|
||||
{t('compare.summary.pageLabel', 'Page')}{' '}{lastPage}
|
||||
{renderedPageNumbers && !renderedPageNumbers.has(lastPage) && (
|
||||
<span className="compare-dropdown-rendering-flag"> — {t('compare.rendering.rendering', 'rendering')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
<Combobox.Option
|
||||
value={item.value}
|
||||
key={item.value}
|
||||
onClick={() => {
|
||||
onNavigate(item.value, item.pageNumber);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<div className="compare-dropdown-option">
|
||||
<span className="compare-dropdown-option__text">{item.label}</span>
|
||||
</div>
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
return nodes;
|
||||
})()
|
||||
) : (
|
||||
<Combobox.Empty>{t('compare.dropdown.noResults', 'No changes found')}</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</ScrollArea.Autosize>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareNavigationDropdown;
|
||||
@ -0,0 +1,421 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { Loader, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import {
|
||||
mapChangesForDropdown,
|
||||
getFileFromSelection,
|
||||
getStubFromSelection,
|
||||
computeShowProgressBanner,
|
||||
computeProgressPct,
|
||||
computeCountsText,
|
||||
computeMaxSharedPages,
|
||||
} from '@app/components/tools/compare/compare';
|
||||
import {
|
||||
CompareResultData,
|
||||
CompareWorkbenchData,
|
||||
} from '@app/types/compare';
|
||||
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||
import { useRightRailButtons } from '@app/hooks/useRightRailButtons';
|
||||
import CompareDocumentPane from '@app/components/tools/compare/CompareDocumentPane';
|
||||
import { useComparePagePreviews } from '@app/components/tools/compare/hooks/useComparePagePreviews';
|
||||
import { useComparePanZoom } from '@app/components/tools/compare/hooks/useComparePanZoom';
|
||||
import { useCompareHighlights } from '@app/components/tools/compare/hooks/useCompareHighlights';
|
||||
import { useCompareChangeNavigation } from '@app/components/tools/compare/hooks/useCompareChangeNavigation';
|
||||
import '@app/components/tools/compare/compareView.css';
|
||||
import { useCompareRightRailButtons } from '@app/components/tools/compare/hooks/useCompareRightRailButtons';
|
||||
import { alert, updateToast, updateToastProgress, dismissToast } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
|
||||
interface CompareWorkbenchViewProps {
|
||||
data: CompareWorkbenchData | null;
|
||||
}
|
||||
|
||||
// helpers moved to compare.ts
|
||||
|
||||
const CompareWorkbenchView = ({ data }: CompareWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const prefersStacked = useIsMobile();
|
||||
const { selectors } = useFileContext();
|
||||
|
||||
const result: CompareResultData | null = data?.result ?? null;
|
||||
const baseFileId = data?.baseFileId ?? null;
|
||||
const comparisonFileId = data?.comparisonFileId ?? null;
|
||||
const isOperationLoading = data?.isLoading ?? false;
|
||||
|
||||
const baseFile = getFileFromSelection(data?.baseLocalFile, baseFileId, selectors);
|
||||
const comparisonFile = getFileFromSelection(data?.comparisonLocalFile, comparisonFileId, selectors);
|
||||
const baseStub = getStubFromSelection(baseFileId, selectors);
|
||||
const comparisonStub = getStubFromSelection(comparisonFileId, selectors);
|
||||
|
||||
const processedAt = result?.totals.processedAt ?? null;
|
||||
|
||||
const { pages: basePages, loading: baseLoading, totalPages: baseTotal, renderedPages: baseRendered } = useComparePagePreviews({
|
||||
file: baseFile,
|
||||
enabled: Boolean(result && baseFile),
|
||||
cacheKey: processedAt,
|
||||
});
|
||||
|
||||
const { pages: comparisonPages, loading: comparisonLoading, totalPages: compTotal, renderedPages: compRendered } = useComparePagePreviews({
|
||||
file: comparisonFile,
|
||||
enabled: Boolean(result && comparisonFile),
|
||||
cacheKey: processedAt,
|
||||
});
|
||||
|
||||
const {
|
||||
layout,
|
||||
toggleLayout,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
handleScrollSync,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
setBaseZoom,
|
||||
comparisonZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
setIsScrollLinked,
|
||||
isScrollLinked,
|
||||
zoomLimits,
|
||||
} = useComparePanZoom({
|
||||
basePages,
|
||||
comparisonPages,
|
||||
prefersStacked,
|
||||
});
|
||||
|
||||
const {
|
||||
baseWordChanges,
|
||||
comparisonWordChanges,
|
||||
metaIndexToGroupId,
|
||||
wordHighlightMaps,
|
||||
getRowHeightPx,
|
||||
} = useCompareHighlights(result, basePages, comparisonPages);
|
||||
|
||||
const temporarilySuppressScrollLink = useCallback((fn: () => void, durationMs = 700) => {
|
||||
const wasLinked = isScrollLinked;
|
||||
if (wasLinked) setIsScrollLinked(false);
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
if (wasLinked) {
|
||||
// recapture anchors to keep panes aligned when relinking
|
||||
captureScrollLinkDelta();
|
||||
setIsScrollLinked(true);
|
||||
}
|
||||
}, Math.max(200, durationMs));
|
||||
}
|
||||
}, [isScrollLinked, setIsScrollLinked, captureScrollLinkDelta]);
|
||||
|
||||
const handleChangeNavigation = useCompareChangeNavigation(
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
{ temporarilySuppressScrollLink }
|
||||
);
|
||||
|
||||
const processingMessage = t('compare.status.processing', 'Analyzing differences...');
|
||||
const baseDocumentLabel = t('compare.summary.baseHeading', 'Original document');
|
||||
const comparisonDocumentLabel = t('compare.summary.comparisonHeading', 'Edited document');
|
||||
const pageLabel = t('compare.summary.pageLabel', 'Page');
|
||||
|
||||
// Always show the selected file names from the sidebar; they are known before diff results
|
||||
const baseTitle = baseStub?.name || result?.base?.fileName || '';
|
||||
const comparisonTitle = comparisonStub?.name || result?.comparison?.fileName || '';
|
||||
|
||||
// During diff processing, show compact spinners in the dropdown badges
|
||||
const baseDropdownPlaceholder = (isOperationLoading || !result)
|
||||
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.deletionsLabel', 'Deletions')} <Loader size="xs" color="currentColor" /></span>)
|
||||
: t('compare.dropdown.deletions', 'Deletions ({{count}})', { count: baseWordChanges.length });
|
||||
const comparisonDropdownPlaceholder = (isOperationLoading || !result)
|
||||
? (<span className="inline-flex flex-row items-center gap-1">{t('compare.dropdown.additionsLabel', 'Additions')} <Loader size="xs" color="currentColor" /></span>)
|
||||
: t('compare.dropdown.additions', 'Additions ({{count}})', { count: comparisonWordChanges.length });
|
||||
|
||||
const rightRailButtons = useCompareRightRailButtons({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
});
|
||||
|
||||
useRightRailButtons(rightRailButtons);
|
||||
|
||||
// Rendering progress toast for very large PDFs
|
||||
const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold
|
||||
const totalsKnown = (baseTotal ?? 0) > 0 && (compTotal ?? 0) > 0;
|
||||
const showProgressBanner = useMemo(() => (
|
||||
computeShowProgressBanner(totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading, LARGE_PAGE_THRESHOLD)
|
||||
), [totalsKnown, baseTotal, compTotal, baseLoading, comparisonLoading]);
|
||||
|
||||
const progressPct = computeProgressPct(totalsKnown, baseTotal, compTotal, baseRendered, compRendered);
|
||||
|
||||
const progressToastIdRef = useRef<string | null>(null);
|
||||
const completionTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const allDone = useMemo(() => {
|
||||
const baseDone = (baseTotal || basePages.length) > 0 && baseRendered >= (baseTotal || basePages.length);
|
||||
const compDone = (compTotal || comparisonPages.length) > 0 && compRendered >= (compTotal || comparisonPages.length);
|
||||
return baseDone && compDone;
|
||||
}, [baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length]);
|
||||
|
||||
// Drive toast lifecycle and progress updates
|
||||
useEffect(() => {
|
||||
// No toast needed
|
||||
if (!showProgressBanner) {
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const countsText = computeCountsText(
|
||||
baseRendered,
|
||||
baseTotal,
|
||||
basePages.length,
|
||||
compRendered,
|
||||
compTotal,
|
||||
comparisonPages.length,
|
||||
);
|
||||
if (!allDone) {
|
||||
// Create toast if missing
|
||||
if (!progressToastIdRef.current) {
|
||||
const id = alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
durationMs: 0,
|
||||
expandable: false,
|
||||
progressBarPercentage: progressPct,
|
||||
});
|
||||
progressToastIdRef.current = id;
|
||||
} else {
|
||||
updateToast(progressToastIdRef.current, {
|
||||
title: t('compare.rendering.inProgress', "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete"),
|
||||
body: `${countsText} ${t('compare.rendering.pagesRendered', 'pages rendered')}`,
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
alertType: 'neutral', // ensure it stays neutral until completion
|
||||
});
|
||||
updateToastProgress(progressToastIdRef.current, progressPct);
|
||||
}
|
||||
} else {
|
||||
// Completed: update then auto-dismiss after 3s
|
||||
if (progressToastIdRef.current) {
|
||||
updateToast(progressToastIdRef.current, {
|
||||
title: t('compare.rendering.complete', 'Page rendering complete'),
|
||||
body: undefined,
|
||||
isPersistentPopup: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
updateToastProgress(progressToastIdRef.current, 100);
|
||||
if (completionTimerRef.current != null) window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = window.setTimeout(() => {
|
||||
if (progressToastIdRef.current) {
|
||||
dismissToast(progressToastIdRef.current);
|
||||
progressToastIdRef.current = null;
|
||||
}
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (completionTimerRef.current != null) {
|
||||
window.clearTimeout(completionTimerRef.current);
|
||||
completionTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showProgressBanner, allDone, progressPct, baseRendered, compRendered, baseTotal, compTotal, basePages.length, comparisonPages.length, t]);
|
||||
|
||||
// Shared page navigation state/input
|
||||
const maxSharedPages = useMemo(() => (
|
||||
computeMaxSharedPages(baseTotal, compTotal, basePages.length, comparisonPages.length)
|
||||
), [baseTotal, compTotal, basePages.length, comparisonPages.length]);
|
||||
|
||||
const [pageInputValue, setPageInputValue] = useState<string>('1');
|
||||
const typingTimerRef = useRef<number | null>(null);
|
||||
const isTypingRef = useRef(false);
|
||||
|
||||
// Clamp the displayed input if max changes smaller than current
|
||||
useEffect(() => {
|
||||
if (!pageInputValue) return;
|
||||
const n = Math.max(1, parseInt(pageInputValue, 10) || 1);
|
||||
if (maxSharedPages > 0 && n > maxSharedPages) {
|
||||
setPageInputValue(String(maxSharedPages));
|
||||
}
|
||||
}, [maxSharedPages]);
|
||||
|
||||
const scrollBothToPage = useCallback((pageNum: number) => {
|
||||
const scrollOne = (container: HTMLDivElement | null) => {
|
||||
if (!container) return false;
|
||||
const pageEl = container.querySelector(`.compare-diff-page[data-page-number="${pageNum}"]`) as HTMLElement | null;
|
||||
if (!pageEl) return false;
|
||||
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desired = Math.max(0, Math.min(maxTop, pageEl.offsetTop - Math.round(container.clientHeight * 0.2)));
|
||||
container.scrollTop = desired;
|
||||
return true;
|
||||
};
|
||||
|
||||
const hitBase = scrollOne(baseScrollRef.current);
|
||||
const hitComp = scrollOne(comparisonScrollRef.current);
|
||||
|
||||
// Warn if one or both pages are not yet rendered
|
||||
const baseHas = basePages.some(p => p.pageNumber === pageNum);
|
||||
const compHas = comparisonPages.some(p => p.pageNumber === pageNum);
|
||||
if (!baseHas || !compHas) {
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('compare.rendering.pageNotReadyTitle', 'Page not rendered yet'),
|
||||
body: t('compare.rendering.pageNotReadyBody', 'Some pages are still rendering. Navigation will snap once they are ready.'),
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
|
||||
return hitBase || hitComp;
|
||||
}, [basePages, comparisonPages, baseScrollRef, comparisonScrollRef, t]);
|
||||
|
||||
const handleTypingChange = useCallback((next: string) => {
|
||||
// Only digits; allow empty while editing
|
||||
const digits = next.replace(/[^0-9]/g, '');
|
||||
if (digits.length === 0) {
|
||||
setPageInputValue('');
|
||||
if (typingTimerRef.current != null) {
|
||||
window.clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Math.max(1, parseInt(digits, 10));
|
||||
const capped = maxSharedPages > 0 ? Math.min(parsed, maxSharedPages) : parsed;
|
||||
const display = String(capped);
|
||||
setPageInputValue(display);
|
||||
|
||||
isTypingRef.current = true;
|
||||
if (typingTimerRef.current != null) window.clearTimeout(typingTimerRef.current);
|
||||
typingTimerRef.current = window.setTimeout(() => {
|
||||
isTypingRef.current = false;
|
||||
scrollBothToPage(capped);
|
||||
}, 300);
|
||||
}, [maxSharedPages, scrollBothToPage]);
|
||||
|
||||
return (
|
||||
<Stack className="compare-workbench">
|
||||
|
||||
<Stack gap="lg" className="compare-workbench__content">
|
||||
<div
|
||||
className={`compare-workbench__columns ${layout === 'stacked' ? 'compare-workbench__columns--stacked' : ''}`}
|
||||
>
|
||||
<CompareDocumentPane
|
||||
pane="base"
|
||||
layout={layout}
|
||||
scrollRef={baseScrollRef}
|
||||
peerScrollRef={comparisonScrollRef}
|
||||
handleScrollSync={handleScrollSync}
|
||||
handleWheelZoom={handleWheelZoom}
|
||||
handleWheelOverscroll={handleWheelOverscroll}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
isPanMode={isPanMode}
|
||||
zoom={baseZoom}
|
||||
title={baseTitle}
|
||||
dropdownPlaceholder={baseDropdownPlaceholder}
|
||||
changes={mapChangesForDropdown(baseWordChanges)}
|
||||
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'base', pageNumber)}
|
||||
isLoading={isOperationLoading || baseLoading}
|
||||
processingMessage={processingMessage}
|
||||
pages={basePages}
|
||||
pairedPages={comparisonPages}
|
||||
getRowHeightPx={getRowHeightPx}
|
||||
wordHighlightMap={wordHighlightMaps.base}
|
||||
metaIndexToGroupId={metaIndexToGroupId.base}
|
||||
documentLabel={baseDocumentLabel}
|
||||
pageLabel={pageLabel}
|
||||
altLabel={baseDocumentLabel}
|
||||
pageInputValue={pageInputValue}
|
||||
onPageInputChange={handleTypingChange}
|
||||
maxSharedPages={maxSharedPages}
|
||||
/>
|
||||
<CompareDocumentPane
|
||||
pane="comparison"
|
||||
layout={layout}
|
||||
scrollRef={comparisonScrollRef}
|
||||
peerScrollRef={baseScrollRef}
|
||||
handleScrollSync={handleScrollSync}
|
||||
handleWheelZoom={handleWheelZoom}
|
||||
handleWheelOverscroll={handleWheelOverscroll}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
isPanMode={isPanMode}
|
||||
zoom={comparisonZoom}
|
||||
title={comparisonTitle}
|
||||
dropdownPlaceholder={comparisonDropdownPlaceholder}
|
||||
changes={mapChangesForDropdown(comparisonWordChanges)}
|
||||
onNavigateChange={(value, pageNumber) => handleChangeNavigation(value, 'comparison', pageNumber)}
|
||||
isLoading={isOperationLoading || comparisonLoading}
|
||||
processingMessage={processingMessage}
|
||||
pages={comparisonPages}
|
||||
pairedPages={basePages}
|
||||
getRowHeightPx={getRowHeightPx}
|
||||
wordHighlightMap={wordHighlightMaps.comparison}
|
||||
metaIndexToGroupId={metaIndexToGroupId.comparison}
|
||||
documentLabel={comparisonDocumentLabel}
|
||||
pageLabel={pageLabel}
|
||||
altLabel={comparisonDocumentLabel}
|
||||
pageInputValue={pageInputValue}
|
||||
onPageInputChange={handleTypingChange}
|
||||
maxSharedPages={maxSharedPages}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareWorkbenchView;
|
||||
239
frontend/src/core/components/tools/compare/compare.ts
Normal file
239
frontend/src/core/components/tools/compare/compare.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import type { TokenBoundingBox, WordHighlightEntry } from '@app/types/compare';
|
||||
import type { FileId } from '@app/types/file';
|
||||
import type { StirlingFile, StirlingFileStub } from '@app/types/fileContext';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
|
||||
/** Convert hex color (#rrggbb) to rgba() string with alpha; falls back to input if invalid. */
|
||||
export const toRgba = (hexColor: string, alpha: number): string => {
|
||||
const hex = hexColor.replace('#', '');
|
||||
if (hex.length !== 6) return hexColor;
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
/** Normalize rotation to [0, 360). */
|
||||
export const normalizeRotation = (deg: number | undefined | null): number => {
|
||||
const n = ((deg ?? 0) % 360 + 360) % 360;
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge overlapping or touching rectangles into larger non-overlapping blocks.
|
||||
* Robust across rotations (vertical groups) and prevents dark spots from overlaps.
|
||||
*/
|
||||
export const mergeConnectedRects = (rects: TokenBoundingBox[]): TokenBoundingBox[] => {
|
||||
if (rects.length === 0) return rects;
|
||||
const EPS = 0.004; // small tolerance in normalized page coords
|
||||
const sorted = rects
|
||||
.slice()
|
||||
.sort((a, b) => (a.top !== b.top ? a.top - b.top : a.left - b.left));
|
||||
const merged: TokenBoundingBox[] = [];
|
||||
|
||||
const overlapsOrTouches = (a: TokenBoundingBox, b: TokenBoundingBox) => {
|
||||
const aR = a.left + a.width;
|
||||
const aB = a.top + a.height;
|
||||
const bR = b.left + b.width;
|
||||
const bB = b.top + b.height;
|
||||
return !(b.left > aR + EPS || bR < a.left - EPS || b.top > aB + EPS || bB < a.top - EPS);
|
||||
};
|
||||
|
||||
for (const r of sorted) {
|
||||
let mergedIntoExisting = false;
|
||||
for (let i = 0; i < merged.length; i += 1) {
|
||||
const m = merged[i];
|
||||
if (overlapsOrTouches(m, r)) {
|
||||
const left = Math.min(m.left, r.left);
|
||||
const top = Math.min(m.top, r.top);
|
||||
const right = Math.max(m.left + m.width, r.left + r.width);
|
||||
const bottom = Math.max(m.top + m.height, r.top + r.height);
|
||||
merged[i] = {
|
||||
left,
|
||||
top,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
mergedIntoExisting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mergedIntoExisting) merged.push({ ...r });
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
/** Group word rectangles by change id using metaIndexToGroupId. */
|
||||
export const groupWordRects = (
|
||||
wordRects: WordHighlightEntry[],
|
||||
metaIndexToGroupId: Map<number, string>,
|
||||
pane: 'base' | 'comparison'
|
||||
): Map<string, TokenBoundingBox[]> => {
|
||||
const groupedRects = new Map<string, TokenBoundingBox[]>();
|
||||
for (const { rect, metaIndex } of wordRects) {
|
||||
const id = metaIndexToGroupId.get(metaIndex) ?? `${pane}-token-${metaIndex}`;
|
||||
const current = groupedRects.get(id) ?? [];
|
||||
current.push(rect);
|
||||
groupedRects.set(id, current);
|
||||
}
|
||||
return groupedRects;
|
||||
};
|
||||
|
||||
/** Compute derived layout metrics for a page render, given environment and zoom. */
|
||||
export const computePageLayoutMetrics = (args: {
|
||||
page: PagePreview;
|
||||
peerPage?: PagePreview | null;
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
isMobileViewport: boolean;
|
||||
scrollRefWidth: number | null;
|
||||
viewportWidth: number;
|
||||
zoom: number;
|
||||
offsetPixels: number; // highlight offset in px relative to original page height
|
||||
}) => {
|
||||
const { page, peerPage, layout, isMobileViewport, scrollRefWidth, viewportWidth, zoom, offsetPixels } = args;
|
||||
const targetHeight = peerPage ? Math.max(page.height, peerPage.height) : page.height;
|
||||
const fit = targetHeight / page.height;
|
||||
const highlightOffset = offsetPixels / page.height;
|
||||
const rotationNorm = normalizeRotation(page.rotation);
|
||||
const isPortrait = rotationNorm === 0 || rotationNorm === 180;
|
||||
const isStackedPortrait = layout === 'stacked' && isPortrait;
|
||||
|
||||
const containerW = scrollRefWidth ?? viewportWidth;
|
||||
const stackedWidth = isMobileViewport
|
||||
? Math.max(320, Math.round(containerW))
|
||||
: Math.max(320, Math.round(viewportWidth * 0.5));
|
||||
const stackedHeight = Math.round(stackedWidth * 1.4142);
|
||||
|
||||
const baseWidth = isStackedPortrait ? stackedWidth : Math.round(page.width * fit);
|
||||
const baseHeight = isStackedPortrait ? stackedHeight : Math.round(targetHeight);
|
||||
const containerMaxW = scrollRefWidth ?? viewportWidth;
|
||||
|
||||
// Container-first zooming with a stable baseline:
|
||||
// Treat zoom=1 as "fit to available width" for the page's base size so
|
||||
// the initial render is fully visible and centered (no cropping), regardless
|
||||
// of rotation or pane/container width. When zoom < 1, shrink the container;
|
||||
// when zoom > 1, keep the container at fit width and scale inner content.
|
||||
const MIN_CONTAINER_WIDTH = 120;
|
||||
const minScaleByWidth = MIN_CONTAINER_WIDTH / Math.max(1, baseWidth);
|
||||
const fitScaleByContainer = containerMaxW / Math.max(1, baseWidth);
|
||||
// Effective baseline scale used at zoom=1 (ensures at least the min width)
|
||||
const baselineContainerScale = Math.max(minScaleByWidth, fitScaleByContainer);
|
||||
// Lower bound the zoom so interactions remain stable
|
||||
const desiredZoom = Math.max(0.1, zoom);
|
||||
|
||||
let containerScale: number;
|
||||
let innerScale: number;
|
||||
if (desiredZoom >= 1) {
|
||||
// At or above baseline: keep container at fit width and scale inner content
|
||||
containerScale = baselineContainerScale;
|
||||
innerScale = +Math.max(0.1, desiredZoom).toFixed(4);
|
||||
} else {
|
||||
// Below baseline: shrink container proportionally, do not upscale inner
|
||||
const scaled = baselineContainerScale * desiredZoom;
|
||||
// Never smaller than minimum readable width
|
||||
containerScale = Math.max(minScaleByWidth, scaled);
|
||||
innerScale = 1;
|
||||
}
|
||||
|
||||
const containerWidth = Math.max(
|
||||
MIN_CONTAINER_WIDTH,
|
||||
Math.min(containerMaxW, Math.round(baseWidth * containerScale))
|
||||
);
|
||||
const containerHeight = Math.round(baseHeight * (containerWidth / Math.max(1, baseWidth)));
|
||||
|
||||
return {
|
||||
targetHeight,
|
||||
fit,
|
||||
highlightOffset,
|
||||
rotationNorm,
|
||||
isPortrait,
|
||||
isStackedPortrait,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
containerMaxW,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
innerScale,
|
||||
};
|
||||
};
|
||||
|
||||
/** Map changes to dropdown options tuple. */
|
||||
export const mapChangesForDropdown = (
|
||||
changes: Array<{ value: string; label: string; pageNumber: number }>
|
||||
) => changes.map(({ value, label, pageNumber }) => ({ value, label, pageNumber }));
|
||||
|
||||
/** File selection helpers */
|
||||
export const getFileFromSelection = (
|
||||
explicit: StirlingFile | null | undefined,
|
||||
fileId: FileId | null,
|
||||
selectors: { getFile: (id: FileId) => StirlingFile | undefined | null }
|
||||
): StirlingFile | null => {
|
||||
if (explicit) return explicit;
|
||||
if (!fileId) return null;
|
||||
return (selectors.getFile(fileId) as StirlingFile | undefined | null) ?? null;
|
||||
};
|
||||
|
||||
export const getStubFromSelection = (
|
||||
fileId: FileId | null,
|
||||
selectors: { getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined }
|
||||
): StirlingFileStub | null => {
|
||||
if (!fileId) return null;
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
return stub ?? null;
|
||||
};
|
||||
|
||||
/** Progress banner computations */
|
||||
export const computeShowProgressBanner = (
|
||||
totalsKnown: boolean,
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseLoading: boolean,
|
||||
compLoading: boolean,
|
||||
threshold: number = 400
|
||||
): boolean => {
|
||||
if (!totalsKnown) return false;
|
||||
const totals = [baseTotal ?? 0, compTotal ?? 0];
|
||||
return Math.max(...totals) >= threshold && (baseLoading || compLoading);
|
||||
};
|
||||
|
||||
export const computeProgressPct = (
|
||||
totalsKnown: boolean,
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseRendered: number,
|
||||
compRendered: number
|
||||
): number => {
|
||||
const totalCombined = totalsKnown ? ((baseTotal ?? 0) + (compTotal ?? 0)) : 0;
|
||||
const renderedCombined = baseRendered + compRendered;
|
||||
return totalsKnown && totalCombined > 0
|
||||
? Math.min(100, Math.round((renderedCombined / totalCombined) * 100))
|
||||
: 0;
|
||||
};
|
||||
|
||||
export const computeCountsText = (
|
||||
baseRendered: number,
|
||||
baseTotal: number | null | undefined,
|
||||
baseLength: number,
|
||||
compRendered: number,
|
||||
compTotal: number | null | undefined,
|
||||
compLength: number
|
||||
): string => {
|
||||
const baseTotalShown = baseTotal || baseLength;
|
||||
const compTotalShown = compTotal || compLength;
|
||||
return `${baseRendered}/${baseTotalShown} • ${compRendered}/${compTotalShown}`;
|
||||
};
|
||||
|
||||
export const computeMaxSharedPages = (
|
||||
baseTotal: number | null | undefined,
|
||||
compTotal: number | null | undefined,
|
||||
baseLen: number,
|
||||
compLen: number
|
||||
): number => {
|
||||
const baseMax = baseTotal || baseLen || 0;
|
||||
const compMax = compTotal || compLen || 0;
|
||||
const minKnown = Math.min(baseMax || Infinity, compMax || Infinity);
|
||||
if (!Number.isFinite(minKnown)) return 0;
|
||||
return Math.max(0, minKnown);
|
||||
};
|
||||
|
||||
|
||||
515
frontend/src/core/components/tools/compare/compareView.css
Normal file
515
frontend/src/core/components/tools/compare/compareView.css
Normal file
@ -0,0 +1,515 @@
|
||||
.compare-dropdown-scrollwrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-dropdown-sticky {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .compare-dropdown-sticky {
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.compare-workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-workbench__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.compare-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compare-pane__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.compare-pane__content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compare-workbench__mode {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.compare-workbench__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked mode: two rows with synchronized scroll panes */
|
||||
.compare-workbench__columns--stacked {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
.compare-workbench__columns > div {
|
||||
/* Critical for responsive flex children inside non-wrapping layouts */
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-legend {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sticky header styling overrides */
|
||||
.compare-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-toolbar);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem -0.5rem 0.5rem -0.5rem;
|
||||
}
|
||||
|
||||
/* Dropdown badge-like style - only style the dropdowns, not titles */
|
||||
.compare-changes-select {
|
||||
background: var(--spdf-compare-removed-badge-bg) !important;
|
||||
color: var(--spdf-compare-removed-badge-fg) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.compare-changes-select__placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.compare-changes-select__placeholder .mantine-Loader-root {
|
||||
display: inline-flex;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison {
|
||||
background: var(--spdf-compare-added-badge-bg) !important;
|
||||
color: var(--spdf-compare-added-badge-fg) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* Wider dropdown menu for long block text */
|
||||
.compare-changes-dropdown {
|
||||
min-width: 520px !important;
|
||||
max-width: 70vw !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden; /* prevent inner elements from overflowing rounded edges */
|
||||
border-radius: 8px !important; /* match dropdown container radius */
|
||||
}
|
||||
|
||||
/* Ensure options text uses full width inside wider dropdown */
|
||||
.compare-dropdown-option__text {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Remove default padding inside Mantine options list so sticky header touches edges */
|
||||
.compare-changes-dropdown .mantine-Combobox-options {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.compare-dropdown-scrollwrap { margin: 0; }
|
||||
|
||||
/* Ensure search field sits flush and does not overflow */
|
||||
.compare-changes-dropdown .mantine-Combobox-search {
|
||||
box-sizing: border-box;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-top-left-radius: 8px !important;
|
||||
border-top-right-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown container */
|
||||
.compare-changes-select .mantine-Combobox-dropdown {
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
background-color: var(--bg-surface) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-dropdown {
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
background-color: var(--bg-surface) !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for ScrollArea */
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-muted) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar {
|
||||
width: 6px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-track {
|
||||
background: var(--bg-muted) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong) !important;
|
||||
border-radius: 3px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-ScrollArea-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Style the dropdown options */
|
||||
.compare-changes-select .mantine-Combobox-option {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-option {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-Combobox-option:hover {
|
||||
background-color: var(--spdf-compare-removed-badge-bg) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-option:hover {
|
||||
background-color: var(--spdf-compare-added-badge-bg) !important;
|
||||
}
|
||||
|
||||
/* Style the search input */
|
||||
.compare-changes-select .mantine-Combobox-search {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-search {
|
||||
font-size: 0.875rem !important;
|
||||
padding: 8px 12px !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select .mantine-Combobox-search::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.compare-changes-select--comparison .mantine-Combobox-search::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Style the chevron - ensure proper coloring */
|
||||
.compare-changes-select .mantine-Combobox-chevron,
|
||||
.compare-changes-select--comparison .mantine-Combobox-chevron {
|
||||
color: inherit !important;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Flash/pulse highlight for navigated change */
|
||||
@keyframes compare-flash {
|
||||
0% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||
background-color: rgba(255, 235, 59, 0.2) !important;
|
||||
}
|
||||
25% {
|
||||
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||
box-shadow: 0 0 20px rgba(255, 235, 59, 0.8);
|
||||
background-color: rgba(255, 235, 59, 0.4) !important;
|
||||
}
|
||||
50% {
|
||||
outline: 4px solid rgba(255, 235, 59, 1.0);
|
||||
box-shadow: 0 0 30px rgba(255, 235, 59, 0.9);
|
||||
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||
}
|
||||
75% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.8);
|
||||
box-shadow: 0 0 15px rgba(255, 235, 59, 0.6);
|
||||
background-color: rgba(255, 235, 59, 0.3) !important;
|
||||
}
|
||||
100% {
|
||||
outline: 4px solid rgba(255, 235, 59, 0.0);
|
||||
box-shadow: 0 0 0 rgba(255, 235, 59, 0.0);
|
||||
background-color: rgba(255, 235, 59, 0.0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.compare-diff-highlight--flash {
|
||||
animation: compare-flash 1.5s ease-in-out 1;
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
/* Bonus: temporarily override red/green to yellow during flash for clarity */
|
||||
background-color: rgba(255, 235, 59, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Union overlay for group flash */
|
||||
.compare-diff-flash-overlay {
|
||||
animation: compare-flash 1.5s ease-in-out 1;
|
||||
z-index: 999;
|
||||
background-color: rgba(255, 235, 59, 0.4);
|
||||
pointer-events: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.compare-legend__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.compare-legend__swatch {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.compare-summary__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compare-summary__stat-card {
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.compare-summary__segment {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
.compare-diff-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.compare-diff-page__canvas {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-surface);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Center canvas in stacked portrait mode (width/height set inline) */
|
||||
.compare-diff-page__canvas[style*="margin-left: auto"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.compare-diff-page__canvas--zoom {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.compare-diff-page__inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100%;
|
||||
background-color: #fff; /* ensure stable white backing during load */
|
||||
border: 1px solid var(--border-subtle);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.compare-diff-page__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Centered per-page title wrapper */
|
||||
.compare-page-title {
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.compare-page-title .mantine-Text-root {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
}
|
||||
|
||||
/* Overlay loader to avoid flash when image not yet loaded */
|
||||
.compare-page-loader-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.compare-diff-highlight {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Compare dropdown option formatting (page + clamped text) */
|
||||
.compare-dropdown-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.compare-dropdown-option__page {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.compare-dropdown-option__text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-clamp: 3;
|
||||
}
|
||||
|
||||
/* Non-sticky in-flow group headers; sticky handled by floating header */
|
||||
.compare-dropdown-group {
|
||||
position: static;
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.compare-dropdown-group.compare-dropdown-group--hidden {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .compare-dropdown-group {
|
||||
background: var(--compare-page-label-bg);
|
||||
color: var(--compare-page-label-fg);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Light grey rendering flag next to page labels in the dropdown */
|
||||
.compare-dropdown-rendering-flag {
|
||||
color: var(--text-muted);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Inline paragraph highlights in summary */
|
||||
.compare-inline {
|
||||
border-radius: 0.2rem;
|
||||
padding: 0.05rem 0.15rem;
|
||||
}
|
||||
.compare-inline--removed {
|
||||
background-color: var(--spdf-compare-inline-removed-bg);
|
||||
}
|
||||
.compare-inline--added {
|
||||
background-color: var(--spdf-compare-inline-added-bg);
|
||||
}
|
||||
|
||||
.compare-pane-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bg-background);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Compare tool thumbnail and details (moved from core/tools/compareTool.css) */
|
||||
.compare-tool__thumbnail {
|
||||
width: 4rem;
|
||||
height: 5.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.compare-tool__details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Mobile: remove side margins and let canvases take full width inside column */
|
||||
@media (max-width: 768px) {
|
||||
.compare-workbench__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.compare-diff-page__canvas.compare-diff-page__canvas--zoom {
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.compare-diff-page__inner {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,247 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
type SuppressOptions = {
|
||||
temporarilySuppressScrollLink?: (fn: () => void, durationMs?: number) => void;
|
||||
};
|
||||
|
||||
export const useCompareChangeNavigation = (
|
||||
baseScrollRef: RefObject<HTMLDivElement | null>,
|
||||
comparisonScrollRef: RefObject<HTMLDivElement | null>,
|
||||
options?: SuppressOptions,
|
||||
) => {
|
||||
return useCallback(
|
||||
(changeValue: string, pane: Pane, pageNumber?: number) => {
|
||||
const suppress = <T extends void>(fn: () => T) => {
|
||||
if (options?.temporarilySuppressScrollLink) {
|
||||
options.temporarilySuppressScrollLink(fn, 700);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
const targetRef = pane === 'base' ? baseScrollRef : comparisonScrollRef;
|
||||
const container = targetRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const findNodes = (): HTMLElement[] => {
|
||||
return Array.from(
|
||||
container.querySelectorAll(`[data-change-id="${changeValue}"]`)
|
||||
) as HTMLElement[];
|
||||
};
|
||||
|
||||
const scrollToPageIfNeeded = () => {
|
||||
if (!pageNumber) return false;
|
||||
const pageEl = container.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!pageEl) return false;
|
||||
const top = pageEl.offsetTop - Math.round(container.clientHeight * 0.2);
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: Math.max(0, top), behavior: 'auto' });
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollPeerPageIfPossible = () => {
|
||||
if (!pageNumber) return;
|
||||
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||
const peer = peerRef.current;
|
||||
if (!peer) return;
|
||||
const peerPageEl = peer.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumber}"]`
|
||||
) as HTMLElement | null;
|
||||
if (!peerPageEl) return;
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
peerMaxTop,
|
||||
peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)
|
||||
)
|
||||
);
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top, behavior: 'auto' });
|
||||
});
|
||||
};
|
||||
|
||||
const proceedWithNodes = (nodes: HTMLElement[]) => {
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
// Prefer a percent-in-page based vertical scroll, which is resilient to transforms.
|
||||
const anchor = nodes[0];
|
||||
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||
const inner = anchor.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||
if (pageEl && inner && !Number.isNaN(topPercent)) {
|
||||
const innerRect = inner.getBoundingClientRect();
|
||||
const innerHeight = Math.max(1, innerRect.height);
|
||||
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desiredTop = Math.max(
|
||||
0,
|
||||
Math.min(maxTop, pageEl.offsetTop + absoluteTopInPage - container.clientHeight / 2)
|
||||
);
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: desiredTop, behavior: 'auto' });
|
||||
});
|
||||
} else {
|
||||
// Fallback to bounding-rect based centering if percent approach is unavailable.
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
let minTop = Number.POSITIVE_INFINITY;
|
||||
let minLeft = Number.POSITIVE_INFINITY;
|
||||
let maxBottom = Number.NEGATIVE_INFINITY;
|
||||
let maxRight = Number.NEGATIVE_INFINITY;
|
||||
|
||||
nodes.forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
minTop = Math.min(minTop, rect.top);
|
||||
minLeft = Math.min(minLeft, rect.left);
|
||||
maxBottom = Math.max(maxBottom, rect.bottom);
|
||||
maxRight = Math.max(maxRight, rect.right);
|
||||
});
|
||||
|
||||
const boxHeight = Math.max(1, maxBottom - minTop);
|
||||
const boxWidth = Math.max(1, maxRight - minLeft);
|
||||
const absoluteTop = minTop - containerRect.top + container.scrollTop;
|
||||
const absoluteLeft = minLeft - containerRect.left + container.scrollLeft;
|
||||
const maxTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
const desiredTop = Math.max(0, Math.min(maxTop, absoluteTop - (container.clientHeight - boxHeight) / 2));
|
||||
const desiredLeft = Math.max(0, absoluteLeft - (container.clientWidth - boxWidth) / 2);
|
||||
|
||||
suppress(() => {
|
||||
container.scrollTo({ top: desiredTop, left: desiredLeft, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
|
||||
// Also scroll the peer container to the corresponding location in the
|
||||
// other PDF (same page and approximate vertical position within page),
|
||||
// not just the same list/scroll position.
|
||||
const peerRef = pane === 'base' ? comparisonScrollRef : baseScrollRef;
|
||||
const peer = peerRef.current;
|
||||
if (peer) {
|
||||
// Use the first node as the anchor
|
||||
const anchor = nodes[0];
|
||||
const pageEl = anchor.closest('.compare-diff-page') as HTMLElement | null;
|
||||
const pageNumAttr = pageEl?.getAttribute('data-page-number');
|
||||
const topPercent = parseFloat((anchor as HTMLElement).style.top || '0');
|
||||
if (pageNumAttr) {
|
||||
const peerPageEl = peer.querySelector(
|
||||
`.compare-diff-page[data-page-number="${pageNumAttr}"]`
|
||||
) as HTMLElement | null;
|
||||
const peerInner = peerPageEl?.querySelector('.compare-diff-page__inner') as HTMLElement | null;
|
||||
if (peerPageEl && peerInner) {
|
||||
const innerRect = peerInner.getBoundingClientRect();
|
||||
const innerHeight = Math.max(1, innerRect.height);
|
||||
const absoluteTopInPage = (topPercent / 100) * innerHeight;
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const peerDesiredTop = Math.max(
|
||||
0,
|
||||
Math.min(peerMaxTop, peerPageEl.offsetTop + absoluteTopInPage - peer.clientHeight / 2)
|
||||
);
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top: peerDesiredTop, behavior: 'auto' });
|
||||
});
|
||||
} else if (peerPageEl) {
|
||||
// Fallback: Scroll to page top (clamped)
|
||||
const peerMaxTop = Math.max(0, peer.scrollHeight - peer.clientHeight);
|
||||
const top = Math.max(0, Math.min(peerMaxTop, peerPageEl.offsetTop - Math.round(peer.clientHeight * 0.2)));
|
||||
suppress(() => {
|
||||
peer.scrollTo({ top, behavior: 'auto' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groupsByInner = new Map<HTMLElement, HTMLElement[]>();
|
||||
nodes.forEach((element) => {
|
||||
const inner = element.closest('.compare-diff-page__inner') as HTMLElement | null;
|
||||
if (!inner) return;
|
||||
const list = groupsByInner.get(inner) ?? [];
|
||||
list.push(element);
|
||||
groupsByInner.set(inner, list);
|
||||
});
|
||||
|
||||
groupsByInner.forEach((elements, inner) => {
|
||||
let minL = 100;
|
||||
let minT = 100;
|
||||
let maxR = 0;
|
||||
let maxB = 0;
|
||||
elements.forEach((element) => {
|
||||
const leftPercent = parseFloat(element.style.left) || 0;
|
||||
const topPercent = parseFloat(element.style.top) || 0;
|
||||
const widthPercent = parseFloat(element.style.width) || 0;
|
||||
const heightPercent = parseFloat(element.style.height) || 0;
|
||||
minL = Math.min(minL, leftPercent);
|
||||
minT = Math.min(minT, topPercent);
|
||||
maxR = Math.max(maxR, leftPercent + widthPercent);
|
||||
maxB = Math.max(maxB, topPercent + heightPercent);
|
||||
});
|
||||
const overlay = document.createElement('span');
|
||||
overlay.className = 'compare-diff-flash-overlay';
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.left = `${minL}%`;
|
||||
overlay.style.top = `${minT}%`;
|
||||
overlay.style.width = `${Math.max(0.1, maxR - minL)}%`;
|
||||
overlay.style.height = `${Math.max(0.1, maxB - minT)}%`;
|
||||
inner.appendChild(overlay);
|
||||
window.setTimeout(() => overlay.remove(), 1600);
|
||||
});
|
||||
|
||||
nodes.forEach((element) => {
|
||||
element.classList.remove('compare-diff-highlight--flash');
|
||||
});
|
||||
void container.clientWidth; // Force reflow
|
||||
nodes.forEach((element) => {
|
||||
element.classList.add('compare-diff-highlight--flash');
|
||||
window.setTimeout(() => element.classList.remove('compare-diff-highlight--flash'), 1600);
|
||||
});
|
||||
};
|
||||
|
||||
const nodes = findNodes();
|
||||
if (nodes.length > 0) {
|
||||
proceedWithNodes(nodes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Page-level fallback immediately so the user sees something happen
|
||||
const scrolledPage = scrollToPageIfNeeded();
|
||||
if (scrolledPage) {
|
||||
scrollPeerPageIfPossible();
|
||||
} else {
|
||||
// Even if the page element is not present yet, try to nudge peer pane
|
||||
scrollPeerPageIfPossible();
|
||||
}
|
||||
|
||||
// Wait for highlights to mount (pages/images render progressively)
|
||||
let settled = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (settled) return;
|
||||
const n = findNodes();
|
||||
if (n.length > 0) {
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
proceedWithNodes(n);
|
||||
}
|
||||
});
|
||||
try {
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
// Safety timeout to stop waiting after a while
|
||||
window.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
// We already scrolled to the page above; nothing else to do.
|
||||
}, 5000);
|
||||
},
|
||||
[baseScrollRef, comparisonScrollRef]
|
||||
);
|
||||
};
|
||||
|
||||
export type UseCompareChangeNavigationReturn = ReturnType<typeof useCompareChangeNavigation>;
|
||||
@ -0,0 +1,144 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
CompareFilteredTokenInfo,
|
||||
WordHighlightEntry,
|
||||
CompareResultData,
|
||||
CompareChangeOption,
|
||||
PagePreview,
|
||||
} from '@app/types/compare';
|
||||
|
||||
interface MetaGroupMap {
|
||||
base: Map<number, string>;
|
||||
comparison: Map<number, string>;
|
||||
}
|
||||
|
||||
interface WordHighlightMaps {
|
||||
base: Map<number, WordHighlightEntry[]>;
|
||||
comparison: Map<number, WordHighlightEntry[]>;
|
||||
}
|
||||
|
||||
export interface UseCompareHighlightsResult {
|
||||
baseWordChanges: CompareChangeOption[];
|
||||
comparisonWordChanges: CompareChangeOption[];
|
||||
metaIndexToGroupId: MetaGroupMap;
|
||||
wordHighlightMaps: WordHighlightMaps;
|
||||
getRowHeightPx: (pageNumber: number) => number;
|
||||
}
|
||||
|
||||
const buildWordChanges = (
|
||||
tokens: CompareFilteredTokenInfo[],
|
||||
metaIndexToGroupId: Map<number, string>,
|
||||
groupPrefix: string
|
||||
): CompareChangeOption[] => {
|
||||
metaIndexToGroupId.clear();
|
||||
if (!tokens.length) return [];
|
||||
|
||||
const items: CompareChangeOption[] = [];
|
||||
let currentRun: CompareFilteredTokenInfo[] = [];
|
||||
|
||||
const flushRun = () => {
|
||||
if (currentRun.length === 0) return;
|
||||
const label = currentRun.map((token) => token.token).join(' ').trim();
|
||||
if (label.length === 0) {
|
||||
currentRun = [];
|
||||
return;
|
||||
}
|
||||
const first = currentRun[0];
|
||||
const last = currentRun[currentRun.length - 1];
|
||||
const groupId = `${groupPrefix}-t${first.metaIndex}-t${last.metaIndex}`;
|
||||
currentRun.forEach((token) => {
|
||||
metaIndexToGroupId.set(token.metaIndex, groupId);
|
||||
});
|
||||
const pageNumber = first.page ?? last.page ?? 1;
|
||||
items.push({ value: groupId, label, pageNumber });
|
||||
currentRun = [];
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.hasHighlight && token.bbox) {
|
||||
currentRun.push(token);
|
||||
} else {
|
||||
flushRun();
|
||||
}
|
||||
}
|
||||
flushRun();
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const buildHighlightMap = (
|
||||
tokens: CompareFilteredTokenInfo[]
|
||||
): Map<number, WordHighlightEntry[]> => {
|
||||
const map = new Map<number, WordHighlightEntry[]>();
|
||||
for (const token of tokens) {
|
||||
if (!token.hasHighlight || !token.bbox || token.page == null) continue;
|
||||
const list = map.get(token.page) ?? [];
|
||||
list.push({ rect: token.bbox, metaIndex: token.metaIndex });
|
||||
map.set(token.page, list);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
export const useCompareHighlights = (
|
||||
result: CompareResultData | null,
|
||||
basePages: PagePreview[],
|
||||
comparisonPages: PagePreview[],
|
||||
): UseCompareHighlightsResult => {
|
||||
const baseMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
const comparisonMetaIndexToGroupId = useMemo(() => new Map<number, string>(), []);
|
||||
|
||||
const baseWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.filteredTokenData.base,
|
||||
baseMetaIndexToGroupId,
|
||||
'base-group'
|
||||
);
|
||||
}, [baseMetaIndexToGroupId, result]);
|
||||
|
||||
const comparisonWordChanges = useMemo(() => {
|
||||
if (!result) return [];
|
||||
return buildWordChanges(
|
||||
result.filteredTokenData.comparison,
|
||||
comparisonMetaIndexToGroupId,
|
||||
'comparison-group'
|
||||
);
|
||||
}, [comparisonMetaIndexToGroupId, result]);
|
||||
|
||||
const wordHighlightMaps = useMemo(() => {
|
||||
if (!result) {
|
||||
return {
|
||||
base: new Map<number, WordHighlightEntry[]>(),
|
||||
comparison: new Map<number, WordHighlightEntry[]>(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
base: buildHighlightMap(result.filteredTokenData.base),
|
||||
comparison: buildHighlightMap(result.filteredTokenData.comparison),
|
||||
};
|
||||
}, [result]);
|
||||
|
||||
const getRowHeightPx = useCallback(
|
||||
(pageNumber: number) => {
|
||||
const basePage = basePages.find((page) => page.pageNumber === pageNumber);
|
||||
const comparisonPage = comparisonPages.find((page) => page.pageNumber === pageNumber);
|
||||
const baseHeight = basePage ? basePage.height : 0;
|
||||
const comparisonHeight = comparisonPage ? comparisonPage.height : 0;
|
||||
const rowHeight = Math.max(baseHeight, comparisonHeight);
|
||||
return Math.round(rowHeight);
|
||||
},
|
||||
[basePages, comparisonPages]
|
||||
);
|
||||
|
||||
return {
|
||||
baseWordChanges,
|
||||
comparisonWordChanges,
|
||||
metaIndexToGroupId: {
|
||||
base: baseMetaIndexToGroupId,
|
||||
comparison: comparisonMetaIndexToGroupId,
|
||||
},
|
||||
wordHighlightMaps,
|
||||
getRowHeightPx,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,253 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import type { PagePreview } from '@app/types/compare';
|
||||
|
||||
const DISPLAY_SCALE = 1;
|
||||
|
||||
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||
|
||||
// Observable preview cache so rendering progress can resume across remounts and view switches
|
||||
type CacheEntry = { pages: PagePreview[]; total: number; subscribers: Set<() => void> };
|
||||
const previewCache: Map<string, CacheEntry> = new Map();
|
||||
const latestVersionMap: Map<string, symbol> = new Map();
|
||||
|
||||
const getOrCreateEntry = (key: string): CacheEntry => {
|
||||
let entry = previewCache.get(key);
|
||||
if (!entry) {
|
||||
entry = { pages: [], total: 0, subscribers: new Set() };
|
||||
previewCache.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const notify = (entry: CacheEntry) => {
|
||||
entry.subscribers.forEach((fn) => {
|
||||
try { fn(); } catch { /* no-op */ }
|
||||
});
|
||||
};
|
||||
|
||||
const subscribe = (key: string, fn: () => void): (() => void) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
entry.subscribers.add(fn);
|
||||
return () => entry.subscribers.delete(fn);
|
||||
};
|
||||
|
||||
const appendBatchToCache = (key: string, batch: PagePreview[], provisionalTotal?: number) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
const next = entry.pages.slice();
|
||||
for (const p of batch) {
|
||||
const idx = next.findIndex((x) => x.pageNumber > p.pageNumber);
|
||||
if (idx === -1) next.push(p); else next.splice(idx, 0, p);
|
||||
}
|
||||
entry.pages = next;
|
||||
if (typeof provisionalTotal === 'number' && entry.total === 0) entry.total = provisionalTotal;
|
||||
notify(entry);
|
||||
};
|
||||
|
||||
const setTotalInCache = (key: string, total: number) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
entry.total = total;
|
||||
notify(entry);
|
||||
};
|
||||
|
||||
const replacePagesInCache = (key: string, pages: PagePreview[], total?: number) => {
|
||||
const entry = getOrCreateEntry(key);
|
||||
entry.pages = pages.slice();
|
||||
if (typeof total === 'number') entry.total = total;
|
||||
notify(entry);
|
||||
};
|
||||
|
||||
|
||||
const renderPdfDocumentToImages = async (
|
||||
file: File,
|
||||
onBatch?: (previews: PagePreview[]) => void,
|
||||
batchSize: number = 12,
|
||||
onInitTotal?: (totalPages: number) => void,
|
||||
startAtPage: number = 1,
|
||||
shouldAbort?: () => boolean,
|
||||
): Promise<PagePreview[]> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const previews: PagePreview[] = [];
|
||||
const dpr = getDevicePixelRatio();
|
||||
const renderScale = Math.max(2, Math.min(3, dpr * 2));
|
||||
onInitTotal?.(pdf.numPages);
|
||||
|
||||
let batch: PagePreview[] = [];
|
||||
const shouldStop = () => Boolean(shouldAbort?.());
|
||||
|
||||
for (let pageNumber = Math.max(1, startAtPage); pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
if (shouldStop()) break;
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
|
||||
const renderViewport = page.getViewport({ scale: renderScale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
canvas.width = Math.round(renderViewport.width);
|
||||
canvas.height = Math.round(renderViewport.height);
|
||||
|
||||
if (!context) {
|
||||
page.cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||
if (shouldStop()) break;
|
||||
|
||||
const preview: PagePreview = {
|
||||
pageNumber,
|
||||
width: Math.round(displayViewport.width),
|
||||
height: Math.round(displayViewport.height),
|
||||
rotation: (page.rotate || 0) % 360,
|
||||
url: canvas.toDataURL(),
|
||||
};
|
||||
previews.push(preview);
|
||||
if (onBatch) {
|
||||
batch.push(preview);
|
||||
if (batch.length >= batchSize) {
|
||||
onBatch(batch);
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
page.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
if (shouldStop()) break;
|
||||
}
|
||||
|
||||
if (onBatch && batch.length > 0) onBatch(batch);
|
||||
return previews;
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
}
|
||||
};
|
||||
|
||||
interface UseComparePagePreviewsOptions {
|
||||
file: File | null;
|
||||
enabled: boolean;
|
||||
cacheKey: number | null;
|
||||
}
|
||||
|
||||
export const useComparePagePreviews = ({
|
||||
file,
|
||||
enabled,
|
||||
cacheKey,
|
||||
}: UseComparePagePreviewsOptions) => {
|
||||
const [pages, setPages] = useState<PagePreview[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const inFlightRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!file || !enabled) {
|
||||
setPages([]);
|
||||
setLoading(false);
|
||||
setTotalPages(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const key = `${file.name || 'file'}:${file.size || 0}:${cacheKey ?? 'none'}`;
|
||||
const refreshVersion = Symbol(key);
|
||||
latestVersionMap.set(key, refreshVersion);
|
||||
const entry = getOrCreateEntry(key);
|
||||
const cachedTotal = entry.total ?? (entry.pages.length ?? 0);
|
||||
let lastKnownTotal = cachedTotal;
|
||||
const isFullyCached = Boolean(entry.pages.length > 0 && cachedTotal > 0 && entry.pages.length >= cachedTotal);
|
||||
|
||||
if (entry.pages.length > 0) {
|
||||
const nextPages = entry.pages.slice();
|
||||
setPages(nextPages);
|
||||
setTotalPages(cachedTotal);
|
||||
} else {
|
||||
setTotalPages(0);
|
||||
}
|
||||
|
||||
setLoading(!isFullyCached);
|
||||
|
||||
const unsubscribe = subscribe(key, () => {
|
||||
const e = getOrCreateEntry(key);
|
||||
setPages(e.pages.slice());
|
||||
setTotalPages(e.total);
|
||||
const done = e.pages.length > 0 && e.total > 0 && e.pages.length >= e.total;
|
||||
setLoading(!done);
|
||||
});
|
||||
|
||||
if (isFullyCached) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const render = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
inFlightRef.current += 1;
|
||||
const current = inFlightRef.current;
|
||||
const startAt = (entry?.pages?.length ?? 0) + 1;
|
||||
const previews = await renderPdfDocumentToImages(
|
||||
file,
|
||||
(batch) => {
|
||||
if (cancelled || current !== inFlightRef.current) return;
|
||||
appendBatchToCache(key, batch, lastKnownTotal || cachedTotal);
|
||||
},
|
||||
16,
|
||||
(total) => {
|
||||
if (!cancelled && current === inFlightRef.current) {
|
||||
lastKnownTotal = total;
|
||||
setTotalInCache(key, total);
|
||||
}
|
||||
},
|
||||
startAt,
|
||||
() => cancelled || current !== inFlightRef.current
|
||||
);
|
||||
if (!cancelled && current === inFlightRef.current) {
|
||||
const stillLatest = latestVersionMap.get(key) === refreshVersion;
|
||||
if (!stillLatest) {
|
||||
return;
|
||||
}
|
||||
const cacheEntry = getOrCreateEntry(key);
|
||||
const finalTotal = lastKnownTotal || cachedTotal || cacheEntry.total || previews.length;
|
||||
lastKnownTotal = finalTotal;
|
||||
const cachePages = cacheEntry.pages ?? [];
|
||||
const preferPreviews = previews.length > cachePages.length;
|
||||
const finalPages = preferPreviews ? previews.slice() : cachePages.slice();
|
||||
replacePagesInCache(key, finalPages, finalTotal);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[compare] failed to render document preview', error);
|
||||
if (!cancelled) {
|
||||
setPages([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [file, enabled, cacheKey]);
|
||||
|
||||
return { pages, loading, totalPages, renderedPages: pages.length };
|
||||
};
|
||||
|
||||
export type UseComparePagePreviewsReturn = ReturnType<typeof useComparePagePreviews>;
|
||||
@ -0,0 +1,895 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
import type {
|
||||
PagePreview,
|
||||
ComparePane as Pane,
|
||||
PanState,
|
||||
ScrollLinkDelta,
|
||||
ScrollLinkAnchors,
|
||||
PanDragState,
|
||||
PinchState,
|
||||
UseComparePanZoomOptions,
|
||||
UseComparePanZoomReturn,
|
||||
} from '@app/types/compare';
|
||||
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 100000;
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
// Default structural adjustments applied to each rendered page row. These are
|
||||
// refined at runtime via DOM measurements once the panes have mounted.
|
||||
const DEFAULT_ROW_STRUCTURAL_EXTRA = 32;
|
||||
const DEFAULT_ROW_GAP = 8;
|
||||
|
||||
// (Interfaces moved to @app/types/compare)
|
||||
|
||||
export const useComparePanZoom = ({
|
||||
basePages,
|
||||
comparisonPages,
|
||||
prefersStacked,
|
||||
}: UseComparePanZoomOptions): UseComparePanZoomReturn => {
|
||||
const baseScrollRef = useRef<HTMLDivElement>(null);
|
||||
const comparisonScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isSyncingRef = useRef(false);
|
||||
const userScrollRef = useRef<{ base: boolean; comparison: boolean }>({ base: false, comparison: false });
|
||||
const scrollLinkDeltaRef = useRef<ScrollLinkDelta>({ vertical: 0, horizontal: 0 });
|
||||
const scrollLinkAnchorsRef = useRef<ScrollLinkAnchors>({
|
||||
deltaPixelsBaseToComp: 0,
|
||||
deltaPixelsCompToBase: 0,
|
||||
});
|
||||
const [isScrollLinked, setIsScrollLinked] = useState(true);
|
||||
const [isPanMode, setIsPanMode] = useState(false);
|
||||
const panDragRef = useRef<PanDragState>({
|
||||
active: false,
|
||||
source: null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startPanX: 0,
|
||||
startPanY: 0,
|
||||
targetStartPanX: 0,
|
||||
targetStartPanY: 0,
|
||||
});
|
||||
const lastActivePaneRef = useRef<Pane>('base');
|
||||
const [baseZoom, setBaseZoom] = useState(1);
|
||||
const [comparisonZoom, setComparisonZoom] = useState(1);
|
||||
const [basePan, setBasePan] = useState<PanState>({ x: 0, y: 0 });
|
||||
const [comparisonPan, setComparisonPan] = useState<PanState>({ x: 0, y: 0 });
|
||||
const wheelZoomAccumRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||
const pinchRef = useRef<PinchState>({ active: false, pane: null, startDistance: 0, startZoom: 1 });
|
||||
const edgeOverscrollRef = useRef<{ base: number; comparison: number }>({ base: 0, comparison: 0 });
|
||||
const [rowStructuralExtraPx, setRowStructuralExtraPx] = useState(DEFAULT_ROW_STRUCTURAL_EXTRA);
|
||||
const [rowGapPx, setRowGapPx] = useState(DEFAULT_ROW_GAP);
|
||||
|
||||
const [layout, setLayoutState] = useState<'side-by-side' | 'stacked'>(prefersStacked ? 'stacked' : 'side-by-side');
|
||||
const setLayout = useCallback((next: 'side-by-side' | 'stacked') => {
|
||||
setLayoutState(next);
|
||||
}, []);
|
||||
const toggleLayout = useCallback(() => {
|
||||
setLayoutState(prev => (prev === 'side-by-side' ? 'stacked' : 'side-by-side'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLayoutState(prev => (prefersStacked ? 'stacked' : prev === 'stacked' ? 'side-by-side' : prev));
|
||||
}, [prefersStacked]);
|
||||
|
||||
const getPagesForPane = useCallback(
|
||||
(pane: Pane) => (pane === 'base' ? basePages : comparisonPages),
|
||||
[basePages, comparisonPages]
|
||||
);
|
||||
|
||||
// rAF-coalesced follower scroll writes
|
||||
const syncRafRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||
const desiredTopRef = useRef<{ base: number | null; comparison: number | null }>({ base: null, comparison: null });
|
||||
|
||||
const canonicalLayout = useMemo(() => {
|
||||
const baseMap = new Map<number, PagePreview>();
|
||||
const compMap = new Map<number, PagePreview>();
|
||||
for (const page of basePages) baseMap.set(page.pageNumber, page);
|
||||
for (const page of comparisonPages) compMap.set(page.pageNumber, page);
|
||||
|
||||
const allPageNumbers = Array.from(
|
||||
new Set([
|
||||
...basePages.map(p => p.pageNumber),
|
||||
...comparisonPages.map(p => p.pageNumber),
|
||||
])
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
const rows = allPageNumbers.map(pageNumber => {
|
||||
const basePage = baseMap.get(pageNumber) ?? null;
|
||||
const compPage = compMap.get(pageNumber) ?? null;
|
||||
const canonicalHeight = Math.max(basePage?.height ?? 0, compPage?.height ?? 0);
|
||||
return {
|
||||
pageNumber,
|
||||
canonicalHeight,
|
||||
hasBase: Boolean(basePage),
|
||||
hasComparison: Boolean(compPage),
|
||||
};
|
||||
});
|
||||
|
||||
const totalCanonicalHeight = rows.reduce((sum, row) => sum + Math.round(row.canonicalHeight), 0);
|
||||
return { rows, totalCanonicalHeight };
|
||||
}, [basePages, comparisonPages]);
|
||||
|
||||
// Measure structural padding (labels, internal gaps) and the inter-row gap
|
||||
// so the scroll mapper can account for real DOM layout instead of relying on
|
||||
// bare page image heights.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (canonicalLayout.rows.length === 0) return;
|
||||
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
const sourceContent =
|
||||
baseScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content') ??
|
||||
comparisonScrollRef.current?.querySelector<HTMLElement>('.compare-pane__content');
|
||||
if (!sourceContent) return;
|
||||
|
||||
const style = window.getComputedStyle(sourceContent);
|
||||
const gapStr = style.rowGap || style.gap;
|
||||
const parsedGap = gapStr ? Number.parseFloat(gapStr) : Number.NaN;
|
||||
const measuredGap = Number.isNaN(parsedGap) ? rowGapPx : Math.max(0, Math.round(parsedGap));
|
||||
if (measuredGap !== rowGapPx) {
|
||||
setRowGapPx(measuredGap);
|
||||
}
|
||||
|
||||
const totalGap = Math.max(0, canonicalLayout.rows.length - 1) * measuredGap;
|
||||
const contentHeight = Math.round(sourceContent.scrollHeight);
|
||||
const available = contentHeight - totalGap - canonicalLayout.totalCanonicalHeight;
|
||||
const candidate = canonicalLayout.rows.length > 0
|
||||
? Math.max(0, Math.round(available / canonicalLayout.rows.length))
|
||||
: 0;
|
||||
|
||||
if (Math.abs(candidate - rowStructuralExtraPx) >= 1) {
|
||||
setRowStructuralExtraPx(candidate);
|
||||
}
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [canonicalLayout, rowGapPx, rowStructuralExtraPx, layout]);
|
||||
|
||||
// Build per-row heights using the same rule as the renderer: pair pages by pageNumber and use the max height
|
||||
const rowHeights = useMemo(() => {
|
||||
const totalRows = canonicalLayout.rows.length;
|
||||
const base: number[] = [];
|
||||
const comp: number[] = [];
|
||||
for (let index = 0; index < totalRows; index += 1) {
|
||||
const row = canonicalLayout.rows[index];
|
||||
const canonicalHeight = Math.round(row.canonicalHeight);
|
||||
const structuralHeight = Math.max(0, Math.round(canonicalHeight + rowStructuralExtraPx));
|
||||
const includeGap = index < totalRows - 1 ? rowGapPx : 0;
|
||||
const totalHeight = structuralHeight + includeGap;
|
||||
if (row.hasBase) base.push(totalHeight);
|
||||
else if (row.hasComparison) base.push(totalHeight);
|
||||
if (row.hasComparison) comp.push(totalHeight);
|
||||
else if (row.hasBase) comp.push(totalHeight);
|
||||
}
|
||||
|
||||
const prefix = (arr: number[]) => {
|
||||
const out: number[] = new Array(arr.length + 1);
|
||||
out[0] = 0;
|
||||
for (let i = 0; i < arr.length; i += 1) out[i + 1] = out[i] + arr[i];
|
||||
return out;
|
||||
};
|
||||
|
||||
return {
|
||||
base,
|
||||
comp,
|
||||
basePrefix: prefix(base),
|
||||
compPrefix: prefix(comp),
|
||||
};
|
||||
}, [canonicalLayout.rows, rowGapPx, rowStructuralExtraPx]);
|
||||
|
||||
const mapScrollTopBetweenPanes = useCallback(
|
||||
(sourceTop: number, sourceIsBase: boolean): number => {
|
||||
const srcHeights = sourceIsBase ? rowHeights.base : rowHeights.comp;
|
||||
const dstHeights = sourceIsBase ? rowHeights.comp : rowHeights.base;
|
||||
const srcPrefix = sourceIsBase ? rowHeights.basePrefix : rowHeights.compPrefix;
|
||||
const dstPrefix = sourceIsBase ? rowHeights.compPrefix : rowHeights.basePrefix;
|
||||
|
||||
if (dstHeights.length === 0 || srcHeights.length === 0) return sourceTop;
|
||||
|
||||
// Clamp to valid range
|
||||
const srcMax = Math.max(0, srcPrefix[srcPrefix.length - 1] - 1);
|
||||
const top = Math.max(0, Math.min(srcMax, Math.floor(sourceTop)));
|
||||
|
||||
// Binary search to find page index i where srcPrefix[i] <= top < srcPrefix[i+1]
|
||||
let lo = 0;
|
||||
let hi = srcHeights.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi + 1) / 2);
|
||||
if (srcPrefix[mid] <= top) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
const i = lo;
|
||||
const within = top - srcPrefix[i];
|
||||
const frac = srcHeights[i] > 0 ? within / srcHeights[i] : 0;
|
||||
|
||||
const j = Math.min(i, dstHeights.length - 1);
|
||||
const dstTop = dstPrefix[j] + frac * (dstHeights[j] || 1);
|
||||
return dstTop;
|
||||
},
|
||||
[rowHeights]
|
||||
);
|
||||
|
||||
const getMaxCanvasSize = useCallback(
|
||||
(pane: Pane) => {
|
||||
const pages = getPagesForPane(pane);
|
||||
const peers = getPagesForPane(pane === 'base' ? 'comparison' : 'base');
|
||||
let maxW = 0;
|
||||
let maxH = 0;
|
||||
for (const page of pages) {
|
||||
const peer = peers.find(p => p.pageNumber === page.pageNumber);
|
||||
const targetHeight = peer ? Math.max(page.height, peer.height) : page.height;
|
||||
const fit = targetHeight / page.height;
|
||||
const width = Math.round(page.width * fit);
|
||||
const height = Math.round(targetHeight);
|
||||
if (width > maxW) maxW = width;
|
||||
if (height > maxH) maxH = height;
|
||||
}
|
||||
return { maxW, maxH };
|
||||
},
|
||||
[getPagesForPane]
|
||||
);
|
||||
|
||||
const getPanBounds = useCallback(
|
||||
(pane: Pane, zoomOverride?: number) => {
|
||||
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const canvasEl = container?.querySelector('.compare-diff-page__canvas') as HTMLElement | null;
|
||||
let canvasW: number | null = null;
|
||||
let canvasH: number | null = null;
|
||||
if (canvasEl) {
|
||||
const rect = canvasEl.getBoundingClientRect();
|
||||
canvasW = Math.max(0, Math.round(rect.width));
|
||||
canvasH = Math.max(0, Math.round(rect.height));
|
||||
}
|
||||
|
||||
const fallback = getMaxCanvasSize(pane);
|
||||
const W = canvasW ?? fallback.maxW;
|
||||
const H = canvasH ?? fallback.maxH;
|
||||
const zoom = zoomOverride !== undefined ? zoomOverride : pane === 'base' ? baseZoom : comparisonZoom;
|
||||
const extraX = Math.max(0, W * (Math.max(zoom, 1) - 1));
|
||||
const extraY = Math.max(0, H * (Math.max(zoom, 1) - 1));
|
||||
return { maxX: extraX, maxY: extraY };
|
||||
},
|
||||
[baseZoom, comparisonZoom, getMaxCanvasSize]
|
||||
);
|
||||
|
||||
const getPaneRotation = useCallback(
|
||||
(pane: Pane) => {
|
||||
const pages = getPagesForPane(pane);
|
||||
const rotation = pages[0]?.rotation ?? 0;
|
||||
const normalized = ((rotation % 360) + 360) % 360;
|
||||
return normalized as 0 | 90 | 180 | 270 | number;
|
||||
},
|
||||
[getPagesForPane]
|
||||
);
|
||||
|
||||
const mapPanBetweenOrientations = useCallback(
|
||||
(source: Pane, target: Pane, sourcePan: PanState) => {
|
||||
const sourceRotation = getPaneRotation(source);
|
||||
const targetRotation = getPaneRotation(target);
|
||||
const sourceBounds = getPanBounds(source);
|
||||
const targetBounds = getPanBounds(target);
|
||||
|
||||
const sx = sourceBounds.maxX === 0 ? 0 : (sourcePan.x / sourceBounds.maxX) * 2 - 1;
|
||||
const sy = sourceBounds.maxY === 0 ? 0 : (sourcePan.y / sourceBounds.maxY) * 2 - 1;
|
||||
|
||||
const applyRotation = (nx: number, ny: number, rotation: number) => {
|
||||
const r = ((rotation % 360) + 360) % 360;
|
||||
if (r === 0) return { nx, ny };
|
||||
if (r === 90) return { nx: ny, ny: -nx };
|
||||
if (r === 180) return { nx: -nx, ny: -ny };
|
||||
if (r === 270) return { nx: -ny, ny: nx };
|
||||
return { nx, ny };
|
||||
};
|
||||
|
||||
const logical = applyRotation(sx, sy, sourceRotation);
|
||||
const targetCentered = applyRotation(logical.nx, logical.ny, 360 - targetRotation);
|
||||
|
||||
const targetNormX = (targetCentered.nx + 1) / 2;
|
||||
const targetNormY = (targetCentered.ny + 1) / 2;
|
||||
|
||||
const targetX = Math.max(0, Math.min(targetBounds.maxX, targetNormX * targetBounds.maxX));
|
||||
const targetY = Math.max(0, Math.min(targetBounds.maxY, targetNormY * targetBounds.maxY));
|
||||
return { x: targetX, y: targetY };
|
||||
},
|
||||
[getPaneRotation, getPanBounds]
|
||||
);
|
||||
|
||||
const centerPanForZoom = useCallback(
|
||||
(pane: Pane, zoomValue: number) => {
|
||||
const bounds = getPanBounds(pane, zoomValue);
|
||||
const center = { x: Math.round(bounds.maxX / 2), y: Math.round(bounds.maxY / 2) };
|
||||
if (pane === 'base') {
|
||||
setBasePan(center);
|
||||
} else {
|
||||
setComparisonPan(center);
|
||||
}
|
||||
},
|
||||
[getPanBounds]
|
||||
);
|
||||
|
||||
const setPanToTopLeft = useCallback((pane: Pane) => {
|
||||
if (pane === 'base') {
|
||||
setBasePan({ x: 0, y: 0 });
|
||||
} else {
|
||||
setComparisonPan({ x: 0, y: 0 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clampPanForZoom = useCallback(
|
||||
(pane: Pane, zoomValue: number) => {
|
||||
const bounds = getPanBounds(pane, zoomValue);
|
||||
const current = pane === 'base' ? basePan : comparisonPan;
|
||||
const clamped = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, current.x)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, current.y)),
|
||||
};
|
||||
if (pane === 'base') {
|
||||
setBasePan(clamped);
|
||||
} else {
|
||||
setComparisonPan(clamped);
|
||||
}
|
||||
},
|
||||
[basePan, comparisonPan, getPanBounds]
|
||||
);
|
||||
|
||||
const handleScrollSync = useCallback(
|
||||
(source: HTMLDivElement | null, target: HTMLDivElement | null) => {
|
||||
if (panDragRef.current.active) return;
|
||||
if (!source || !target || isSyncingRef.current || !isScrollLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceIsBase = source === baseScrollRef.current;
|
||||
const sourceKey = sourceIsBase ? 'base' : 'comparison';
|
||||
|
||||
// Only sync if this scroll was initiated by the user (wheel/scrollbar/keyboard),
|
||||
// not by our own programmatic scrolls.
|
||||
if (!userScrollRef.current[sourceKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActivePaneRef.current = sourceIsBase ? 'base' : 'comparison';
|
||||
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
|
||||
// Use pixel anchors captured at link time to preserve offset
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
|
||||
const rawDesired = mappedTop + deltaPx;
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, rawDesired));
|
||||
|
||||
// If the mapping requests a position beyond target bounds and the target is already
|
||||
// at that bound, skip writing to avoid any subtle feedback that could impede
|
||||
// continued scrolling in the source pane.
|
||||
const atTopBound = desiredTop === 0 && target.scrollTop === 0 && rawDesired < 0;
|
||||
const atBottomBound = desiredTop === targetVerticalRange && target.scrollTop === targetVerticalRange && rawDesired > targetVerticalRange;
|
||||
if (atTopBound || atBottomBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetIsBase = target === baseScrollRef.current;
|
||||
const key = targetIsBase ? 'base' : 'comparison';
|
||||
|
||||
desiredTopRef.current[key] = desiredTop;
|
||||
if (syncRafRef.current[key] == null) {
|
||||
syncRafRef.current[key] = requestAnimationFrame(() => {
|
||||
const el = targetIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const top = desiredTopRef.current[key] ?? 0;
|
||||
if (el) {
|
||||
isSyncingRef.current = true;
|
||||
el.scrollTop = top;
|
||||
}
|
||||
syncRafRef.current[key] = null;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||
);
|
||||
|
||||
// Track user-initiated scroll state per pane
|
||||
useEffect(() => {
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) return;
|
||||
|
||||
const onUserScrollStartBase = () => { userScrollRef.current.base = true; };
|
||||
const onUserScrollStartComp = () => { userScrollRef.current.comparison = true; };
|
||||
const onUserScrollEndBase = () => { userScrollRef.current.base = false; };
|
||||
const onUserScrollEndComp = () => { userScrollRef.current.comparison = false; };
|
||||
|
||||
const addUserListeners = (el: HTMLDivElement, onStart: () => void, onEnd: () => void) => {
|
||||
el.addEventListener('wheel', onStart, { passive: true });
|
||||
el.addEventListener('mousedown', onStart, { passive: true });
|
||||
el.addEventListener('touchstart', onStart, { passive: true });
|
||||
// Heuristic: clear the flag shortly after scroll events settle
|
||||
let timeout: number | null = null;
|
||||
const onScroll = () => {
|
||||
// Ignore programmatic scrolls to avoid feedback loops and unnecessary syncing work
|
||||
if (isSyncingRef.current) return;
|
||||
onStart();
|
||||
if (timeout != null) window.clearTimeout(timeout);
|
||||
timeout = window.setTimeout(onEnd, 120);
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onStart as any);
|
||||
el.removeEventListener('mousedown', onStart as any);
|
||||
el.removeEventListener('touchstart', onStart as any);
|
||||
el.removeEventListener('scroll', onScroll as any);
|
||||
if (timeout != null) window.clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupBase = addUserListeners(baseEl, onUserScrollStartBase, onUserScrollEndBase);
|
||||
const cleanupComp = addUserListeners(compEl, onUserScrollStartComp, onUserScrollEndComp);
|
||||
return () => { cleanupBase(); cleanupComp(); };
|
||||
}, []);
|
||||
|
||||
// Helpers for clearer pan-edge overscroll behavior
|
||||
const getVerticalOverflow = useCallback((rawY: number, maxY: number): number => {
|
||||
if (rawY < 0) return rawY; // negative -> scroll up
|
||||
if (rawY > maxY) return rawY - maxY; // positive -> scroll down
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
const normalizeApplyCandidate = useCallback((overflowY: number): number => {
|
||||
const DEADZONE = 32; // pixels
|
||||
if (overflowY < -DEADZONE) return overflowY + DEADZONE;
|
||||
if (overflowY > DEADZONE) return overflowY - DEADZONE;
|
||||
return 0;
|
||||
}, []);
|
||||
|
||||
const applyIncrementalScroll = useCallback((container: HTMLDivElement, isBase: boolean, applyCandidate: number) => {
|
||||
const STEP = 48; // pixels per incremental scroll
|
||||
const key = isBase ? 'base' : 'comparison';
|
||||
const deltaSinceLast = applyCandidate - edgeOverscrollRef.current[key];
|
||||
const magnitude = Math.abs(deltaSinceLast);
|
||||
if (magnitude < STEP) return;
|
||||
|
||||
const stepDelta = Math.sign(deltaSinceLast) * Math.floor(magnitude / STEP) * STEP;
|
||||
edgeOverscrollRef.current[key] += stepDelta;
|
||||
|
||||
const prevTop = container.scrollTop;
|
||||
const nextTop = Math.max(0, Math.min(container.scrollHeight - container.clientHeight, prevTop + stepDelta));
|
||||
if (nextTop === prevTop) return;
|
||||
|
||||
container.scrollTop = nextTop;
|
||||
if (isScrollLinked) {
|
||||
const sourceIsBase = isBase;
|
||||
const target = isBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (target) {
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const mappedTop = mapScrollTopBetweenPanes(nextTop, sourceIsBase);
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||
target.scrollTop = desiredTop;
|
||||
}
|
||||
}
|
||||
}, [isScrollLinked, mapScrollTopBetweenPanes]);
|
||||
|
||||
const handlePanEdgeOverscroll = useCallback((rawY: number, boundsMaxY: number, isBase: boolean) => {
|
||||
const container = isBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (!container) return;
|
||||
const overflowY = getVerticalOverflow(rawY, boundsMaxY);
|
||||
const applyCandidate = normalizeApplyCandidate(overflowY);
|
||||
if (applyCandidate !== 0) {
|
||||
applyIncrementalScroll(container, isBase, applyCandidate);
|
||||
} else {
|
||||
// Reset accumulator when back within deadzone
|
||||
edgeOverscrollRef.current[isBase ? 'base' : 'comparison'] = 0;
|
||||
}
|
||||
}, [applyIncrementalScroll, getVerticalOverflow, normalizeApplyCandidate]);
|
||||
|
||||
const beginPan = useCallback(
|
||||
(pane: Pane, event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isPanMode) return;
|
||||
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
if (zoom <= 1) return;
|
||||
const container = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const targetEl = event.target as HTMLElement | null;
|
||||
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||
if (!isOnImage) return;
|
||||
|
||||
event.preventDefault();
|
||||
panDragRef.current = {
|
||||
active: true,
|
||||
source: pane,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||
};
|
||||
edgeOverscrollRef.current[pane] = 0;
|
||||
lastActivePaneRef.current = pane;
|
||||
(container as HTMLDivElement).style.cursor = 'grabbing';
|
||||
},
|
||||
[isPanMode, baseZoom, comparisonZoom, basePan, comparisonPan]
|
||||
);
|
||||
|
||||
const continuePan = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isPanMode) return;
|
||||
const drag = panDragRef.current;
|
||||
if (!drag.active || !drag.source) return;
|
||||
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
|
||||
const isBase = drag.source === 'base';
|
||||
const bounds = getPanBounds(drag.source);
|
||||
const rawX = drag.startPanX - dx;
|
||||
const rawY = drag.startPanY - dy;
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||
};
|
||||
|
||||
// On vertical overscroll beyond pan bounds, scroll the page (with deadzone + incremental steps)
|
||||
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
},
|
||||
[getPanBounds, isPanMode, isScrollLinked, mapPanBetweenOrientations]
|
||||
);
|
||||
|
||||
const endPan = useCallback(() => {
|
||||
const drag = panDragRef.current;
|
||||
if (!drag.active) return;
|
||||
const sourceEl = drag.source === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
if (sourceEl) {
|
||||
const zoom = drag.source === 'base' ? baseZoom : comparisonZoom;
|
||||
(sourceEl as HTMLDivElement).style.cursor = isPanMode ? (zoom > 1 ? 'grab' : 'auto') : '';
|
||||
}
|
||||
panDragRef.current.active = false;
|
||||
panDragRef.current.source = null;
|
||||
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||
|
||||
const handleWheelZoom = useCallback(
|
||||
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (!event.ctrlKey) return;
|
||||
event.preventDefault();
|
||||
const key = pane === 'base' ? 'base' : 'comparison';
|
||||
const accum = wheelZoomAccumRef.current;
|
||||
const threshold = 180;
|
||||
accum[key] += event.deltaY;
|
||||
const steps = Math.trunc(Math.abs(accum[key]) / threshold);
|
||||
if (steps <= 0) return;
|
||||
const direction = accum[key] > 0 ? -1 : 1;
|
||||
accum[key] = accum[key] % threshold;
|
||||
const applySteps = (zoom: number) => {
|
||||
let next = zoom;
|
||||
for (let i = 0; i < steps; i += 1) {
|
||||
next = direction > 0
|
||||
? Math.min(ZOOM_MAX, +(next + ZOOM_STEP).toFixed(2))
|
||||
: Math.max(ZOOM_MIN, +(next - ZOOM_STEP).toFixed(2));
|
||||
}
|
||||
return next;
|
||||
};
|
||||
if (pane === 'base') {
|
||||
const prev = baseZoom;
|
||||
const next = applySteps(prev);
|
||||
setBaseZoom(next);
|
||||
if (next < prev) {
|
||||
centerPanForZoom('base', next);
|
||||
} else {
|
||||
clampPanForZoom('base', next);
|
||||
}
|
||||
} else {
|
||||
const prev = comparisonZoom;
|
||||
const next = applySteps(prev);
|
||||
setComparisonZoom(next);
|
||||
if (next < prev) {
|
||||
centerPanForZoom('comparison', next);
|
||||
} else {
|
||||
clampPanForZoom('comparison', next);
|
||||
}
|
||||
}
|
||||
},
|
||||
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom]
|
||||
);
|
||||
|
||||
// When the source pane hits its scroll limit but the peer still has room,
|
||||
// propagate the wheel delta to the peer so it continues following.
|
||||
const handleWheelOverscroll = useCallback(
|
||||
(pane: Pane, event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (event.ctrlKey) return; // handled by zoom handler
|
||||
if (!isScrollLinked) return;
|
||||
|
||||
const source = pane === 'base' ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const target = pane === 'base' ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (!source || !target) return;
|
||||
|
||||
const deltaY = event.deltaY;
|
||||
if (deltaY === 0) return;
|
||||
|
||||
const sourceMax = Math.max(0, source.scrollHeight - source.clientHeight);
|
||||
const nextSource = Math.max(0, Math.min(sourceMax, source.scrollTop + deltaY));
|
||||
|
||||
// If the source can scroll, let the normal scroll event drive syncing
|
||||
if (nextSource !== source.scrollTop) return;
|
||||
|
||||
// Source is at a bound; push mapped delta into the target
|
||||
const sourceIsBase = pane === 'base';
|
||||
|
||||
// Map the desired new source position (scrollTop + deltaY) into target space
|
||||
const mappedBefore = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
const mappedAfter = mapScrollTopBetweenPanes(source.scrollTop + deltaY, sourceIsBase);
|
||||
const mappedDelta = mappedAfter - mappedBefore;
|
||||
|
||||
// Include the pixel anchor captured when linking
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
|
||||
const targetMax = Math.max(0, target.scrollHeight - target.clientHeight);
|
||||
const desired = Math.max(0, Math.min(targetMax, target.scrollTop + (mappedDelta || deltaY)));
|
||||
|
||||
if (desired !== target.scrollTop) {
|
||||
isSyncingRef.current = true;
|
||||
// Adjust relative to mapped space to keep the anchor consistent
|
||||
const anchored = Math.max(0, Math.min(targetMax, mappedBefore + deltaPx + (mappedDelta || deltaY)));
|
||||
target.scrollTop = anchored;
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[isScrollLinked, mapScrollTopBetweenPanes]
|
||||
);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(pane: Pane, event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (event.touches.length === 2) {
|
||||
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
const dy = t1.clientY - t2.clientY;
|
||||
pinchRef.current = {
|
||||
active: true,
|
||||
pane,
|
||||
startDistance: Math.hypot(dx, dy),
|
||||
startZoom: pane === 'base' ? baseZoom : comparisonZoom,
|
||||
};
|
||||
event.preventDefault();
|
||||
} else if (event.touches.length === 1) {
|
||||
if (!isPanMode) return;
|
||||
const zoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
if (zoom <= 1) return;
|
||||
const targetEl = event.target as HTMLElement | null;
|
||||
const isOnImage = !!targetEl?.closest('.compare-diff-page__inner');
|
||||
if (!isOnImage) return;
|
||||
const touch = event.touches[0];
|
||||
panDragRef.current = {
|
||||
active: true,
|
||||
source: pane,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
startPanX: pane === 'base' ? basePan.x : comparisonPan.x,
|
||||
startPanY: pane === 'base' ? basePan.y : comparisonPan.y,
|
||||
targetStartPanX: pane === 'base' ? comparisonPan.x : basePan.x,
|
||||
targetStartPanY: pane === 'base' ? comparisonPan.y : basePan.y,
|
||||
};
|
||||
edgeOverscrollRef.current[pane] = 0;
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[basePan, baseZoom, comparisonPan, comparisonZoom, isPanMode]
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(event: ReactTouchEvent<HTMLDivElement>) => {
|
||||
if (pinchRef.current.active && event.touches.length === 2) {
|
||||
const [t1, t2] = [event.touches[0], event.touches[1]];
|
||||
const dx = t1.clientX - t2.clientX;
|
||||
const dy = t1.clientY - t2.clientY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const scale = distance / Math.max(1, pinchRef.current.startDistance);
|
||||
const dampened = 1 + (scale - 1) * 0.6;
|
||||
const pane = pinchRef.current.pane!;
|
||||
const startZoom = pinchRef.current.startZoom;
|
||||
const previousZoom = pane === 'base' ? baseZoom : comparisonZoom;
|
||||
const nextZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, +(startZoom * dampened).toFixed(2)));
|
||||
if (pane === 'base') {
|
||||
setBaseZoom(nextZoom);
|
||||
if (nextZoom < previousZoom) {
|
||||
centerPanForZoom('base', nextZoom);
|
||||
} else if (nextZoom > previousZoom) {
|
||||
clampPanForZoom('base', nextZoom);
|
||||
}
|
||||
} else {
|
||||
setComparisonZoom(nextZoom);
|
||||
if (nextZoom < previousZoom) {
|
||||
centerPanForZoom('comparison', nextZoom);
|
||||
} else if (nextZoom > previousZoom) {
|
||||
clampPanForZoom('comparison', nextZoom);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (panDragRef.current.active && event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
const dx = touch.clientX - panDragRef.current.startX;
|
||||
const dy = touch.clientY - panDragRef.current.startY;
|
||||
const isBase = panDragRef.current.source === 'base';
|
||||
const bounds = getPanBounds(panDragRef.current.source!);
|
||||
const rawX = panDragRef.current.startPanX - dx;
|
||||
const rawY = panDragRef.current.startPanY - dy;
|
||||
const desired = {
|
||||
x: Math.max(0, Math.min(bounds.maxX, rawX)),
|
||||
y: Math.max(0, Math.min(bounds.maxY, rawY)),
|
||||
};
|
||||
|
||||
handlePanEdgeOverscroll(rawY, bounds.maxY, isBase);
|
||||
if (isBase) {
|
||||
setBasePan(desired);
|
||||
} else {
|
||||
setComparisonPan(desired);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[baseZoom, clampPanForZoom, centerPanForZoom, comparisonZoom, getPanBounds, isScrollLinked, mapPanBetweenOrientations]
|
||||
);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
pinchRef.current.active = false;
|
||||
pinchRef.current.pane = null;
|
||||
panDragRef.current.active = false;
|
||||
}, []);
|
||||
|
||||
// Auto-toggle Pan Mode based on zoom level
|
||||
useEffect(() => {
|
||||
const shouldPan = baseZoom > 1 || comparisonZoom > 1;
|
||||
if (isPanMode !== shouldPan) setIsPanMode(shouldPan);
|
||||
}, [baseZoom, comparisonZoom, isPanMode]);
|
||||
|
||||
// When new pages render and extend scrollHeight, re-apply the mapping so
|
||||
// the follower continues tracking instead of getting stuck at its prior max.
|
||||
useEffect(() => {
|
||||
if (!isScrollLinked) return;
|
||||
const sourceIsBase = lastActivePaneRef.current === 'base';
|
||||
const source = sourceIsBase ? baseScrollRef.current : comparisonScrollRef.current;
|
||||
const target = sourceIsBase ? comparisonScrollRef.current : baseScrollRef.current;
|
||||
if (!source || !target) return;
|
||||
|
||||
const mappedTop = mapScrollTopBetweenPanes(source.scrollTop, sourceIsBase);
|
||||
const deltaPx = sourceIsBase
|
||||
? scrollLinkAnchorsRef.current.deltaPixelsBaseToComp
|
||||
: scrollLinkAnchorsRef.current.deltaPixelsCompToBase;
|
||||
const targetVerticalRange = Math.max(1, target.scrollHeight - target.clientHeight);
|
||||
const desiredTop = Math.max(0, Math.min(targetVerticalRange, mappedTop + deltaPx));
|
||||
|
||||
if (Math.abs(target.scrollTop - desiredTop) > 1) {
|
||||
isSyncingRef.current = true;
|
||||
target.scrollTop = desiredTop;
|
||||
requestAnimationFrame(() => { isSyncingRef.current = false; });
|
||||
}
|
||||
}, [basePages.length, comparisonPages.length, isScrollLinked, mapScrollTopBetweenPanes]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isScrollLinked) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
const tag = (target?.tagName || '').toLowerCase();
|
||||
const isEditable = target && (tag === 'input' || tag === 'textarea' || target.getAttribute('contenteditable') === 'true');
|
||||
if (isEditable) return;
|
||||
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) return;
|
||||
|
||||
const STEP = 80;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const delta = event.key === 'ArrowDown' ? STEP : -STEP;
|
||||
isSyncingRef.current = true;
|
||||
baseEl.scrollTop = Math.max(0, Math.min(baseEl.scrollTop + delta, baseEl.scrollHeight - baseEl.clientHeight));
|
||||
compEl.scrollTop = Math.max(0, Math.min(compEl.scrollTop + delta, compEl.scrollHeight - compEl.clientHeight));
|
||||
requestAnimationFrame(() => {
|
||||
isSyncingRef.current = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [isScrollLinked]);
|
||||
|
||||
const captureScrollLinkDelta = useCallback(() => {
|
||||
const baseEl = baseScrollRef.current;
|
||||
const compEl = comparisonScrollRef.current;
|
||||
if (!baseEl || !compEl) {
|
||||
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
|
||||
return;
|
||||
}
|
||||
const baseVMax = Math.max(1, baseEl.scrollHeight - baseEl.clientHeight);
|
||||
const compVMax = Math.max(1, compEl.scrollHeight - compEl.clientHeight);
|
||||
const baseHMax = Math.max(1, baseEl.scrollWidth - baseEl.clientWidth);
|
||||
const compHMax = Math.max(1, compEl.scrollWidth - compEl.clientWidth);
|
||||
|
||||
const baseV = baseEl.scrollTop / baseVMax;
|
||||
const compV = compEl.scrollTop / compVMax;
|
||||
const baseH = baseEl.scrollLeft / baseHMax;
|
||||
const compH = compEl.scrollLeft / compHMax;
|
||||
|
||||
scrollLinkDeltaRef.current = {
|
||||
vertical: compV - baseV,
|
||||
horizontal: compH - baseH,
|
||||
};
|
||||
|
||||
// Capture pixel anchors in mapped space
|
||||
const mappedBaseToComp = mapScrollTopBetweenPanes(baseEl.scrollTop, true);
|
||||
const mappedCompToBase = mapScrollTopBetweenPanes(compEl.scrollTop, false);
|
||||
scrollLinkAnchorsRef.current = {
|
||||
deltaPixelsBaseToComp: compEl.scrollTop - mappedBaseToComp,
|
||||
deltaPixelsCompToBase: baseEl.scrollTop - mappedCompToBase,
|
||||
};
|
||||
}, [mapScrollTopBetweenPanes]);
|
||||
|
||||
const clearScrollLinkDelta = useCallback(() => {
|
||||
scrollLinkDeltaRef.current = { vertical: 0, horizontal: 0 };
|
||||
scrollLinkAnchorsRef.current = { deltaPixelsBaseToComp: 0, deltaPixelsCompToBase: 0 };
|
||||
}, []);
|
||||
|
||||
const zoomLimits = useMemo(() => ({ min: ZOOM_MIN, max: ZOOM_MAX, step: ZOOM_STEP }), []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
setLayout,
|
||||
toggleLayout,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
captureScrollLinkDelta,
|
||||
clearScrollLinkDelta,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
setBaseZoom,
|
||||
comparisonZoom,
|
||||
setComparisonZoom,
|
||||
basePan,
|
||||
comparisonPan,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
handleScrollSync,
|
||||
beginPan,
|
||||
continuePan,
|
||||
endPan,
|
||||
handleWheelZoom,
|
||||
handleWheelOverscroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
zoomLimits,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,191 @@
|
||||
import { useMemo } from 'react';
|
||||
import type React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
import type { RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
type Pane = 'base' | 'comparison';
|
||||
|
||||
export interface UseCompareRightRailButtonsOptions {
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
toggleLayout: () => void;
|
||||
isPanMode: boolean;
|
||||
setIsPanMode: (value: boolean) => void;
|
||||
baseZoom: number;
|
||||
comparisonZoom: number;
|
||||
setBaseZoom: (value: number) => void;
|
||||
setComparisonZoom: (value: number) => void;
|
||||
setPanToTopLeft: (pane: Pane) => void;
|
||||
centerPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
clampPanForZoom: (pane: Pane, zoom: number) => void;
|
||||
clearScrollLinkDelta: () => void;
|
||||
captureScrollLinkDelta: () => void;
|
||||
isScrollLinked: boolean;
|
||||
setIsScrollLinked: (value: boolean) => void;
|
||||
zoomLimits: { min: number; max: number; step: number };
|
||||
baseScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
comparisonScrollRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const useCompareRightRailButtons = ({
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
setPanToTopLeft,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
baseScrollRef,
|
||||
comparisonScrollRef,
|
||||
}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return useMemo<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'compare-toggle-layout',
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon={layout === 'side-by-side' ? 'vertical-split-rounded' : 'horizontal-split-rounded'}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: layout === 'side-by-side'
|
||||
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||
ariaLabel: layout === 'side-by-side'
|
||||
? t('compare.actions.stackVertically', 'Stack vertically')
|
||||
: t('compare.actions.placeSideBySide', 'Place side by side'),
|
||||
section: 'top',
|
||||
order: 10,
|
||||
onClick: toggleLayout,
|
||||
},
|
||||
{
|
||||
id: 'compare-zoom-out',
|
||||
icon: <LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.zoomOut', 'Zoom out'),
|
||||
ariaLabel: t('compare.actions.zoomOut', 'Zoom out'),
|
||||
section: 'top',
|
||||
order: 13,
|
||||
onClick: () => {
|
||||
const { min, step } = zoomLimits;
|
||||
const nextBase = Math.max(min, +(baseZoom - step).toFixed(2));
|
||||
const nextComparison = Math.max(min, +(comparisonZoom - step).toFixed(2));
|
||||
setBaseZoom(nextBase);
|
||||
setComparisonZoom(nextComparison);
|
||||
centerPanForZoom('base', nextBase);
|
||||
centerPanForZoom('comparison', nextComparison);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-zoom-in',
|
||||
icon: <LocalIcon icon="zoom-in" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.zoomIn', 'Zoom in'),
|
||||
ariaLabel: t('compare.actions.zoomIn', 'Zoom in'),
|
||||
section: 'top',
|
||||
order: 14,
|
||||
onClick: () => {
|
||||
const { max, step } = zoomLimits;
|
||||
const nextBase = Math.min(max, +(baseZoom + step).toFixed(2));
|
||||
const nextComparison = Math.min(max, +(comparisonZoom + step).toFixed(2));
|
||||
setBaseZoom(nextBase);
|
||||
setComparisonZoom(nextComparison);
|
||||
clampPanForZoom('base', nextBase);
|
||||
clampPanForZoom('comparison', nextComparison);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-reset-view',
|
||||
icon: <LocalIcon icon="refresh-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||
ariaLabel: t('compare.actions.resetView', 'Reset zoom and pan'),
|
||||
section: 'top',
|
||||
order: 14.5,
|
||||
disabled: baseZoom === 1 && comparisonZoom === 1,
|
||||
onClick: () => {
|
||||
setBaseZoom(1);
|
||||
setComparisonZoom(1);
|
||||
setPanToTopLeft('base');
|
||||
setPanToTopLeft('comparison');
|
||||
clearScrollLinkDelta();
|
||||
// Reset scrollTop for both panes to realign view
|
||||
if (baseScrollRef?.current) {
|
||||
baseScrollRef.current.scrollTop = 0;
|
||||
}
|
||||
if (comparisonScrollRef?.current) {
|
||||
comparisonScrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compare-toggle-scroll-link',
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon={isScrollLinked ? 'link-rounded' : 'link-off-rounded'}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: isScrollLinked
|
||||
? t('compare.actions.unlinkScroll', 'Unlink scroll')
|
||||
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||
ariaLabel: isScrollLinked
|
||||
? t('compare.actions.unlinkScroll', 'Unlink scroll')
|
||||
: t('compare.actions.linkScroll', 'Link scroll'),
|
||||
section: 'top',
|
||||
order: 15,
|
||||
onClick: () => {
|
||||
const next = !isScrollLinked;
|
||||
if (next) {
|
||||
captureScrollLinkDelta();
|
||||
} else {
|
||||
if (!isMobile) {
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.toasts.unlinkedTitle', 'Independent scroll enabled'),
|
||||
body: t('compare.toasts.unlinkedBody', 'Tip: Arrow Up/Down scroll both panes when unlinked is off.'),
|
||||
durationMs: 5000,
|
||||
location: 'bottom-center' as ToastLocation,
|
||||
expandable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsScrollLinked(next);
|
||||
},
|
||||
},
|
||||
], [
|
||||
layout,
|
||||
toggleLayout,
|
||||
isPanMode,
|
||||
setIsPanMode,
|
||||
baseZoom,
|
||||
comparisonZoom,
|
||||
setBaseZoom,
|
||||
setComparisonZoom,
|
||||
centerPanForZoom,
|
||||
clampPanForZoom,
|
||||
setPanToTopLeft,
|
||||
clearScrollLinkDelta,
|
||||
captureScrollLinkDelta,
|
||||
isScrollLinked,
|
||||
setIsScrollLinked,
|
||||
zoomLimits,
|
||||
t,
|
||||
isMobile,
|
||||
]);
|
||||
};
|
||||
|
||||
export type UseCompareRightRailButtonsReturn = ReturnType<typeof useCompareRightRailButtons>;
|
||||
@ -87,7 +87,7 @@ const FileStatusIndicator = ({
|
||||
<Text size="sm" c="dimmed">
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
onClick={() => openFilesModal({})}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||
@ -122,7 +122,7 @@ const FileStatusIndicator = ({
|
||||
{getPlaceholder() + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
onClick={() => openFilesModal({})}
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<FolderIcon style={{ fontSize: '0.875rem' }} />
|
||||
|
||||
@ -86,7 +86,6 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
{config.steps.map((stepConfig) =>
|
||||
steps.create(stepConfig.title, {
|
||||
isVisible: stepConfig.isVisible,
|
||||
isCollapsed: stepConfig.isCollapsed,
|
||||
onCollapsedClick: stepConfig.onCollapsedClick,
|
||||
tooltip: stepConfig.tooltip
|
||||
}, stepConfig.content)
|
||||
|
||||
@ -13,14 +13,14 @@ export const useOverlayPdfsTips = (): TooltipContent => {
|
||||
title: t('overlay-pdfs.tooltip.description.title', 'Description'),
|
||||
description: t(
|
||||
'overlay-pdfs.tooltip.description.text',
|
||||
'Combine a base PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
|
||||
'Combine a original PDF with one or more overlay PDFs. Overlays can be applied page-by-page in different modes and placed in the foreground or background.'
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('overlay-pdfs.tooltip.mode.title', 'Overlay Mode'),
|
||||
description: t(
|
||||
'overlay-pdfs.tooltip.mode.text',
|
||||
'Choose how to distribute overlay pages across the base PDF pages.'
|
||||
'Choose how to distribute overlay pages across the original PDF pages.'
|
||||
),
|
||||
bullets: [
|
||||
t('overlay-pdfs.tooltip.mode.sequential', 'Sequential Overlay: Use pages from the first overlay PDF until it ends, then move to the next.'),
|
||||
@ -39,7 +39,7 @@ export const useOverlayPdfsTips = (): TooltipContent => {
|
||||
title: t('overlay-pdfs.tooltip.overlayFiles.title', 'Overlay Files'),
|
||||
description: t(
|
||||
'overlay-pdfs.tooltip.overlayFiles.text',
|
||||
'Select one or more PDFs to overlay on the base. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
|
||||
'Select one or more PDFs to overlay on the original. The order of these files affects how pages are applied in Sequential and Fixed Repeat modes.'
|
||||
)
|
||||
},
|
||||
{
|
||||
|
||||
@ -62,8 +62,8 @@ export function useViewerRightRailButtons() {
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanning ? 'filled' : 'subtle'}
|
||||
color={isPanning ? 'blue' : undefined}
|
||||
variant={isPanning ? 'default' : 'subtle'}
|
||||
color={undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
@ -71,6 +71,7 @@ export function useViewerRightRailButtons() {
|
||||
setIsPanning(prev => !prev);
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={isPanning ? { backgroundColor: 'var(--right-rail-pan-active-bg)' } : undefined}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFileActions } from '@app/contexts/FileContext';
|
||||
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import type { FileId } from '@app/types/file';
|
||||
import { fileStorage } from '@app/services/fileStorage';
|
||||
|
||||
interface FilesModalContextType {
|
||||
@ -19,6 +21,7 @@ const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addFiles } = useFileHandler();
|
||||
const { actions } = useFileActions();
|
||||
const fileCtx = useFileContext();
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
||||
@ -37,16 +40,25 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
onModalClose?.();
|
||||
}, [onModalClose]);
|
||||
|
||||
const handleFileUpload = useCallback((files: File[]) => {
|
||||
const handleFileUpload = useCallback(async (files: File[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler(files, insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addFiles(files);
|
||||
// 1) Add via standard flow (auto-selects new files)
|
||||
await addFiles(files);
|
||||
// 2) Merge all requested file IDs (covers already-present files too)
|
||||
const ids = files
|
||||
.map((f) => fileCtx.findFileId(f) as FileId | undefined)
|
||||
.filter((id): id is FileId => Boolean(id));
|
||||
if (ids.length > 0) {
|
||||
const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id);
|
||||
const nextSelection = Array.from(new Set([...currentSelected, ...ids]));
|
||||
actions.setSelectedFiles(nextSelection);
|
||||
}
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
}, [addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx]);
|
||||
|
||||
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
||||
if (customHandler) {
|
||||
@ -67,15 +79,22 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
console.error('Failed to load files for custom handler:', error);
|
||||
}
|
||||
} else {
|
||||
// Normal case - use addStirlingFileStubs to preserve metadata
|
||||
// Normal case - use addStirlingFileStubs to preserve metadata (auto-selects new)
|
||||
if (actions.addStirlingFileStubs) {
|
||||
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
|
||||
await actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
|
||||
// Merge all requested IDs into selection (covers files that already existed)
|
||||
const requestedIds = stirlingFileStubs.map((s) => s.id);
|
||||
if (requestedIds.length > 0) {
|
||||
const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id);
|
||||
const nextSelection = Array.from(new Set([...currentSelected, ...requestedIds]));
|
||||
actions.setSelectedFiles(nextSelection);
|
||||
}
|
||||
} else {
|
||||
console.error('addStirlingFileStubs action not available');
|
||||
}
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
|
||||
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage, actions, fileCtx]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
|
||||
@ -421,4 +421,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
|
||||
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@ -122,8 +122,11 @@ import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbe
|
||||
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
|
||||
import ValidateSignature from "@app/tools/ValidateSignature";
|
||||
import Automate from "@app/tools/Automate";
|
||||
import Compare from "@app/tools/Compare";
|
||||
import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats";
|
||||
|
||||
|
||||
|
||||
export interface TranslatedToolCatalog {
|
||||
allTools: ToolRegistry;
|
||||
regularTools: RegularToolRegistry;
|
||||
@ -772,13 +775,15 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
compare: {
|
||||
icon: <LocalIcon icon="compare-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.compare.title", "Compare"),
|
||||
component: null,
|
||||
component: Compare,
|
||||
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */,
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: 2,
|
||||
operationConfig: undefined,
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "compare"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
supportsAutomate: false
|
||||
},
|
||||
compress: {
|
||||
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
|
||||
|
||||
566
frontend/src/core/hooks/tools/compare/operationUtils.ts
Normal file
566
frontend/src/core/hooks/tools/compare/operationUtils.ts
Normal file
@ -0,0 +1,566 @@
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import { appendWord as sharedAppendWord } from '@app/utils/textDiff';
|
||||
import { PARAGRAPH_SENTINEL } from '@app/types/compare';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import type { PDFPageProxy, TextContent, TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||
import type {
|
||||
CompareChange,
|
||||
CompareDiffToken,
|
||||
CompareResultData,
|
||||
TokenBoundingBox,
|
||||
CompareParagraph,
|
||||
} from '@app/types/compare';
|
||||
|
||||
export interface TokenMetadata {
|
||||
page: number;
|
||||
paragraph: number;
|
||||
bbox: TokenBoundingBox | null;
|
||||
}
|
||||
|
||||
export interface ExtractedContent {
|
||||
tokens: string[];
|
||||
metadata: TokenMetadata[];
|
||||
pageSizes: { width: number; height: number }[];
|
||||
paragraphs: CompareParagraph[];
|
||||
}
|
||||
|
||||
const measurementCanvas = typeof document !== 'undefined' ? document.createElement('canvas') : null;
|
||||
const measurementContext = measurementCanvas ? measurementCanvas.getContext('2d') : null;
|
||||
const textMeasurementCache: Map<string, number> | null = measurementContext ? new Map() : null;
|
||||
let lastMeasurementFont = '';
|
||||
|
||||
const DEFAULT_CHAR_WIDTH = 1;
|
||||
const DEFAULT_SPACE_WIDTH = 0.33;
|
||||
|
||||
export const measureTextWidth = (fontSpec: string, text: string): number => {
|
||||
if (!measurementContext) {
|
||||
if (!text) return 0;
|
||||
if (text === ' ') return DEFAULT_SPACE_WIDTH;
|
||||
return text.length * DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
|
||||
if (lastMeasurementFont !== fontSpec) {
|
||||
measurementContext.font = fontSpec;
|
||||
lastMeasurementFont = fontSpec;
|
||||
}
|
||||
|
||||
const key = `${fontSpec}|${text}`;
|
||||
const cached = textMeasurementCache?.get(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const width = measurementContext.measureText(text).width || 0;
|
||||
textMeasurementCache?.set(key, width);
|
||||
return width;
|
||||
};
|
||||
|
||||
export const appendWord = (existing: string, word: string) => {
|
||||
if (!existing) {
|
||||
return sharedAppendWord('', word);
|
||||
}
|
||||
return sharedAppendWord(existing, word);
|
||||
};
|
||||
|
||||
export const aggregateTotals = (tokens: CompareDiffToken[]) => {
|
||||
return tokens.reduce(
|
||||
(totals, token) => {
|
||||
if (token.text === '\uE000PARA') { // PARAGRAPH_SENTINEL safeguard if serialized
|
||||
return totals;
|
||||
}
|
||||
switch (token.type) {
|
||||
case 'added':
|
||||
totals.added += 1;
|
||||
break;
|
||||
case 'removed':
|
||||
totals.removed += 1;
|
||||
break;
|
||||
default:
|
||||
totals.unchanged += 1;
|
||||
}
|
||||
return totals;
|
||||
},
|
||||
{ added: 0, removed: 0, unchanged: 0 }
|
||||
);
|
||||
};
|
||||
|
||||
export const buildChanges = (
|
||||
tokens: CompareDiffToken[],
|
||||
baseMetadata: TokenMetadata[],
|
||||
comparisonMetadata: TokenMetadata[]
|
||||
): CompareChange[] => {
|
||||
const changes: CompareChange[] = [];
|
||||
let baseIndex = 0;
|
||||
let comparisonIndex = 0;
|
||||
let current: CompareChange | null = null;
|
||||
let currentBaseParagraph: number | null = null;
|
||||
let currentComparisonParagraph: number | null = null;
|
||||
|
||||
const ensureCurrent = (): CompareChange => {
|
||||
if (!current) {
|
||||
current = {
|
||||
id: `change-${changes.length}`,
|
||||
base: null,
|
||||
comparison: null,
|
||||
};
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (current) {
|
||||
if (current.base) {
|
||||
current.base.text = current.base.text.trim();
|
||||
}
|
||||
if (current.comparison) {
|
||||
current.comparison.text = current.comparison.text.trim();
|
||||
}
|
||||
|
||||
if ((current.base?.text && current.base.text.length > 0) || (current.comparison?.text && current.comparison.text.length > 0)) {
|
||||
changes.push(current);
|
||||
}
|
||||
}
|
||||
current = null;
|
||||
currentBaseParagraph = null;
|
||||
currentComparisonParagraph = null;
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'removed') {
|
||||
const meta = baseMetadata[baseIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.base) {
|
||||
active.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentBaseParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentBaseParagraph !== null &&
|
||||
paragraph !== currentBaseParagraph &&
|
||||
active.base.text.trim().length > 0
|
||||
) {
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.base = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.base.text = appendWord(active.base.text, token.text);
|
||||
}
|
||||
if (meta && active.base.page === null) {
|
||||
active.base.page = meta.page;
|
||||
}
|
||||
if (meta && active.base.paragraph === null) {
|
||||
active.base.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentBaseParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === 'added') {
|
||||
const meta = comparisonMetadata[comparisonIndex] ?? null;
|
||||
const active = ensureCurrent();
|
||||
const paragraph = meta?.paragraph ?? null;
|
||||
if (!active.comparison) {
|
||||
active.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
};
|
||||
currentComparisonParagraph = paragraph;
|
||||
} else {
|
||||
if (
|
||||
paragraph !== null &&
|
||||
currentComparisonParagraph !== null &&
|
||||
paragraph !== currentComparisonParagraph &&
|
||||
active.comparison.text.trim().length > 0
|
||||
) {
|
||||
flush();
|
||||
const next = ensureCurrent();
|
||||
next.comparison = {
|
||||
text: token.text,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: paragraph,
|
||||
};
|
||||
} else {
|
||||
active.comparison.text = appendWord(active.comparison.text, token.text);
|
||||
}
|
||||
if (meta && active.comparison.page === null) {
|
||||
active.comparison.page = meta.page;
|
||||
}
|
||||
if (meta && active.comparison.paragraph === null) {
|
||||
active.comparison.paragraph = meta.paragraph;
|
||||
}
|
||||
if (paragraph !== null) {
|
||||
currentComparisonParagraph = paragraph;
|
||||
}
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// unchanged token
|
||||
flush();
|
||||
if (baseIndex < baseMetadata.length) {
|
||||
baseIndex += 1;
|
||||
}
|
||||
if (comparisonIndex < comparisonMetadata.length) {
|
||||
comparisonIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
export const createSummaryFile = (result: CompareResultData): File => {
|
||||
const exportPayload = {
|
||||
generatedAt: new Date(result.totals.processedAt).toISOString(),
|
||||
base: {
|
||||
name: result.base.fileName,
|
||||
totalWords: result.base.wordCount,
|
||||
},
|
||||
comparison: {
|
||||
name: result.comparison.fileName,
|
||||
totalWords: result.comparison.wordCount,
|
||||
},
|
||||
totals: {
|
||||
added: result.totals.added,
|
||||
removed: result.totals.removed,
|
||||
unchanged: result.totals.unchanged,
|
||||
durationMs: result.totals.durationMs,
|
||||
},
|
||||
changes: result.changes.map((change) => ({
|
||||
base: change.base,
|
||||
comparison: change.comparison,
|
||||
})),
|
||||
warnings: result.warnings,
|
||||
};
|
||||
|
||||
const filename = `compare-summary-${new Date(result.totals.processedAt).toISOString().replace(/[:.]/g, '-')}.json`;
|
||||
return new File([JSON.stringify(exportPayload, null, 2)], filename, { type: 'application/json' });
|
||||
};
|
||||
|
||||
export const clamp = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
|
||||
export const getWorkerErrorCode = (value: unknown): 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' | undefined => {
|
||||
if (typeof value === 'object' && value !== null && 'code' in value) {
|
||||
const potentialCode = (value as { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' }).code;
|
||||
return potentialCode;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Produce a filtered view of tokens/metadata that excludes paragraph sentinel markers,
|
||||
// returning a mapping to original indices for potential future use.
|
||||
export const filterTokensForDiff = (
|
||||
tokens: string[],
|
||||
metadata: TokenMetadata[],
|
||||
): { tokens: string[]; metadata: TokenMetadata[]; filteredToOriginal: number[] } => {
|
||||
const outTokens: string[] = [];
|
||||
const outMeta: TokenMetadata[] = [];
|
||||
const map: number[] = [];
|
||||
for (let i = 0; i < tokens.length; i += 1) {
|
||||
const t = tokens[i];
|
||||
const isPara = t === PARAGRAPH_SENTINEL || t.startsWith('\uE000') || t.includes('PARA');
|
||||
if (!isPara) {
|
||||
outTokens.push(t);
|
||||
if (metadata[i]) outMeta.push(metadata[i]);
|
||||
map.push(i);
|
||||
}
|
||||
}
|
||||
return { tokens: outTokens, metadata: outMeta, filteredToOriginal: map };
|
||||
};
|
||||
|
||||
export const extractContentFromPdf = async (file: StirlingFile): Promise<ExtractedContent> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const tokens: string[] = [];
|
||||
const metadata: TokenMetadata[] = [];
|
||||
const pageSizes: { width: number; height: number }[] = [];
|
||||
const paragraphs: CompareParagraph[] = [];
|
||||
for (let pageIndex = 1; pageIndex <= pdfDoc.numPages; pageIndex += 1) {
|
||||
const page: PDFPageProxy = await pdfDoc.getPage(pageIndex);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const content: TextContent = await page.getTextContent({
|
||||
disableCombineTextItems: true,
|
||||
} as Parameters<PDFPageProxy['getTextContent']>[0]);
|
||||
const styles: Record<string, { fontFamily?: string; ascent?: number; descent?: number }> = content.styles ?? {};
|
||||
|
||||
let paragraphIndex = 1;
|
||||
let paragraphBuffer = '';
|
||||
let prevItem: TextItem | null = null;
|
||||
|
||||
pageSizes.push({ width: viewport.width, height: viewport.height });
|
||||
|
||||
const normalizeToken = (s: string) =>
|
||||
s
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u00AD\u200B-\u200F\u202A-\u202E]/g, '')
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/[–—]/g, '-')
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const isParagraphBreak = (curr: TextItem, prev: TextItem | null) => {
|
||||
const hasHardBreak = 'hasEOL' in curr && (curr as TextItem).hasEOL;
|
||||
if (hasHardBreak) return true;
|
||||
if (!prev) return false;
|
||||
const prevY = prev.transform[5];
|
||||
const currY = curr.transform[5];
|
||||
const dy = Math.abs(currY - prevY);
|
||||
const currX = curr.transform[4];
|
||||
const prevX = prev.transform[4];
|
||||
const approxLine = Math.max(10, Math.abs((curr as any).height ?? 0) * 0.9);
|
||||
const looksLikeParagraph = dy > approxLine * 1.8;
|
||||
const likelySoftWrap = currX < prevX && dy < approxLine * 0.6;
|
||||
return looksLikeParagraph && !likelySoftWrap;
|
||||
};
|
||||
|
||||
const adjustBoundingBox = (left: number, top: number, width: number, height: number): TokenBoundingBox | null => {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 0.004;
|
||||
const MIN_HORIZONTAL_PAD = 0.0012;
|
||||
const HORIZONTAL_PAD_RATIO = 0.12;
|
||||
const MIN_VERTICAL_PAD = 0.0008;
|
||||
const VERTICAL_PAD_RATIO = 0.18;
|
||||
|
||||
const horizontalPad = Math.max(width * HORIZONTAL_PAD_RATIO, MIN_HORIZONTAL_PAD);
|
||||
const verticalPad = Math.max(height * VERTICAL_PAD_RATIO, MIN_VERTICAL_PAD);
|
||||
|
||||
let expandedLeft = left - horizontalPad;
|
||||
let expandedRight = left + width + horizontalPad;
|
||||
let expandedTop = top - verticalPad;
|
||||
let expandedBottom = top + height + verticalPad;
|
||||
|
||||
if (expandedRight - expandedLeft < MIN_WIDTH) {
|
||||
const deficit = MIN_WIDTH - (expandedRight - expandedLeft);
|
||||
expandedLeft -= deficit / 2;
|
||||
expandedRight += deficit / 2;
|
||||
}
|
||||
|
||||
expandedLeft = clamp(expandedLeft);
|
||||
expandedRight = clamp(expandedRight);
|
||||
expandedTop = clamp(expandedTop);
|
||||
expandedBottom = clamp(expandedBottom);
|
||||
|
||||
if (expandedRight <= expandedLeft || expandedBottom <= expandedTop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: expandedLeft,
|
||||
top: expandedTop,
|
||||
width: expandedRight - expandedLeft,
|
||||
height: expandedBottom - expandedTop,
|
||||
};
|
||||
};
|
||||
|
||||
for (const item of content.items as TextItem[]) {
|
||||
if (!item?.str) {
|
||||
prevItem = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = item.str;
|
||||
const totalLen = Math.max(rawText.length, 1);
|
||||
const textStyle = item.fontName ? styles[item.fontName] : undefined;
|
||||
const fontFamily = textStyle?.fontFamily ?? 'sans-serif';
|
||||
const fontScale = Math.max(0.5, Math.hypot(item.transform[0], item.transform[1]) || 0);
|
||||
const fontSpec = `${fontScale}px ${fontFamily}`;
|
||||
|
||||
const weights: number[] = new Array(totalLen);
|
||||
let runningText = '';
|
||||
let previousAdvance = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
runningText += rawText[i];
|
||||
const advance = measureTextWidth(fontSpec, runningText);
|
||||
let width = advance - previousAdvance;
|
||||
if (!Number.isFinite(width) || width <= 0) {
|
||||
width = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
weights[i] = width;
|
||||
previousAdvance = advance;
|
||||
}
|
||||
if (!Number.isFinite(previousAdvance) || previousAdvance <= 0) {
|
||||
for (let i = 0; i < totalLen; i += 1) {
|
||||
weights[i] = rawText[i] === ' ' ? DEFAULT_SPACE_WIDTH : DEFAULT_CHAR_WIDTH;
|
||||
}
|
||||
}
|
||||
const prefix: number[] = new Array(totalLen + 1);
|
||||
prefix[0] = 0;
|
||||
for (let i = 0; i < totalLen; i += 1) prefix[i + 1] = prefix[i] + weights[i];
|
||||
const totalWeight = prefix[totalLen] || 1;
|
||||
|
||||
const rawX = item.transform[4];
|
||||
const rawY = item.transform[5];
|
||||
const transformed = [
|
||||
viewport.convertToViewportPoint(rawX, rawY),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY),
|
||||
viewport.convertToViewportPoint(rawX, rawY + item.height),
|
||||
viewport.convertToViewportPoint(rawX + item.width, rawY + item.height),
|
||||
];
|
||||
const xs = transformed.map(([px]) => px);
|
||||
const ys = transformed.map(([, py]) => py);
|
||||
const left = Math.min(...xs);
|
||||
const right = Math.max(...xs);
|
||||
const top = Math.min(...ys);
|
||||
const bottom = Math.max(...ys);
|
||||
|
||||
if (!Number.isFinite(left) || !Number.isFinite(right) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
|
||||
prevItem = item;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [baselineStart, baselineEnd, verticalEnd] = transformed;
|
||||
const baselineVector: [number, number] = [
|
||||
baselineEnd[0] - baselineStart[0],
|
||||
baselineEnd[1] - baselineStart[1],
|
||||
];
|
||||
const verticalVector: [number, number] = [
|
||||
verticalEnd[0] - baselineStart[0],
|
||||
verticalEnd[1] - baselineStart[1],
|
||||
];
|
||||
const baselineMagnitude = Math.hypot(baselineVector[0], baselineVector[1]);
|
||||
const verticalMagnitude = Math.hypot(verticalVector[0], verticalVector[1]);
|
||||
const hasOrientationVectors = baselineMagnitude > 1e-6 && verticalMagnitude > 1e-6;
|
||||
|
||||
const font = item.fontName ? styles[item.fontName] : undefined;
|
||||
const ascent = typeof font?.ascent === 'number' ? Math.max(0.7, Math.min(1.1, font.ascent)) : 0.9;
|
||||
const descent = typeof font?.descent === 'number' ? Math.max(0.0, Math.min(0.5, Math.abs(font.descent))) : 0.2;
|
||||
const verticalScale = Math.min(1, Math.max(0.75, ascent + descent));
|
||||
|
||||
const wordRegex = /[A-Za-z0-9]+|[^\sA-Za-z0-9]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = wordRegex.exec(rawText)) !== null) {
|
||||
const wordRaw = match[0];
|
||||
const normalizedWord = normalizeToken(wordRaw);
|
||||
if (!normalizedWord) {
|
||||
continue;
|
||||
}
|
||||
const startIndex = match.index;
|
||||
const endIndex = startIndex + wordRaw.length;
|
||||
|
||||
const relStart = prefix[startIndex] / totalWeight;
|
||||
const relEnd = prefix[endIndex] / totalWeight;
|
||||
|
||||
let wordLeftAbs: number;
|
||||
let wordRightAbs: number;
|
||||
let wordTopAbs: number;
|
||||
let wordBottomAbs: number;
|
||||
|
||||
if (hasOrientationVectors) {
|
||||
const segStart: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relStart,
|
||||
baselineStart[1] + baselineVector[1] * relStart,
|
||||
];
|
||||
const segEnd: [number, number] = [
|
||||
baselineStart[0] + baselineVector[0] * relEnd,
|
||||
baselineStart[1] + baselineVector[1] * relEnd,
|
||||
];
|
||||
const cornerPoints: Array<[number, number]> = [
|
||||
segStart,
|
||||
[segStart[0] + verticalVector[0], segStart[1] + verticalVector[1]],
|
||||
[segEnd[0] + verticalVector[0], segEnd[1] + verticalVector[1]],
|
||||
segEnd,
|
||||
];
|
||||
const cornerXs = cornerPoints.map(([px]) => px);
|
||||
const cornerYs = cornerPoints.map(([, py]) => py);
|
||||
wordLeftAbs = Math.min(...cornerXs);
|
||||
wordRightAbs = Math.max(...cornerXs);
|
||||
wordTopAbs = Math.min(...cornerYs);
|
||||
wordBottomAbs = Math.max(...cornerYs);
|
||||
} else {
|
||||
const segLeftAbs = left + (right - left) * relStart;
|
||||
const segRightAbs = left + (right - left) * relEnd;
|
||||
wordLeftAbs = Math.min(segLeftAbs, segRightAbs);
|
||||
wordRightAbs = Math.max(segLeftAbs, segRightAbs);
|
||||
wordTopAbs = top;
|
||||
wordBottomAbs = bottom;
|
||||
}
|
||||
|
||||
const wordLeft = clamp(wordLeftAbs / viewport.width);
|
||||
const wordRight = clamp(wordRightAbs / viewport.width);
|
||||
const wordTop = clamp(wordTopAbs / viewport.height);
|
||||
const wordBottom = clamp(wordBottomAbs / viewport.height);
|
||||
const wordWidth = Math.max(0, wordRight - wordLeft);
|
||||
let wordHeight = Math.max(0, wordBottom - wordTop);
|
||||
|
||||
if (wordHeight > 0 && verticalScale < 1) {
|
||||
const midY = (wordTop + wordBottom) / 2;
|
||||
const shrunkHeight = Math.max(0, wordHeight * verticalScale);
|
||||
const half = shrunkHeight / 2;
|
||||
const newTop = clamp(midY - half);
|
||||
const newBottom = clamp(midY + half);
|
||||
wordHeight = Math.max(0, newBottom - newTop);
|
||||
const bbox = adjustBoundingBox(wordLeft, newTop, wordWidth, wordHeight);
|
||||
tokens.push(normalizedWord);
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox });
|
||||
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bbox = adjustBoundingBox(wordLeft, wordTop, wordWidth, wordHeight);
|
||||
|
||||
tokens.push(normalizedWord);
|
||||
metadata.push({
|
||||
page: pageIndex,
|
||||
paragraph: paragraphIndex,
|
||||
bbox,
|
||||
});
|
||||
|
||||
paragraphBuffer = appendWord(paragraphBuffer, normalizedWord);
|
||||
}
|
||||
|
||||
if (isParagraphBreak(item as TextItem, prevItem)) {
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
}
|
||||
tokens.push('\uE000PARA');
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
paragraphIndex += 1;
|
||||
}
|
||||
prevItem = item as TextItem;
|
||||
}
|
||||
|
||||
if (paragraphBuffer.trim().length > 0) {
|
||||
paragraphs.push({ page: pageIndex, paragraph: paragraphIndex, text: paragraphBuffer.trim() });
|
||||
paragraphBuffer = '';
|
||||
tokens.push('\uE000PARA');
|
||||
metadata.push({ page: pageIndex, paragraph: paragraphIndex, bbox: null });
|
||||
}
|
||||
}
|
||||
return { tokens, metadata, pageSizes, paragraphs };
|
||||
} finally {
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
559
frontend/src/core/hooks/tools/compare/useCompareOperation.ts
Normal file
559
frontend/src/core/hooks/tools/compare/useCompareOperation.ts
Normal file
@ -0,0 +1,559 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ADDITION_HIGHLIGHT,
|
||||
CompareDiffToken,
|
||||
CompareFilteredTokenInfo,
|
||||
CompareResultData,
|
||||
CompareWorkerRequest,
|
||||
CompareWorkerResponse,
|
||||
CompareWorkerWarnings,
|
||||
REMOVAL_HIGHLIGHT,
|
||||
} from '@app/types/compare';
|
||||
import { CompareParameters } from '@app/hooks/tools/compare/useCompareParameters';
|
||||
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import { useFileContext } from '@app/contexts/file/fileHooks';
|
||||
import {
|
||||
aggregateTotals,
|
||||
buildChanges,
|
||||
createSummaryFile,
|
||||
extractContentFromPdf,
|
||||
getWorkerErrorCode,
|
||||
filterTokensForDiff,
|
||||
} from '@app/hooks/tools/compare/operationUtils';
|
||||
import { alert, dismissToast } from '@app/components/toast';
|
||||
import type { ToastLocation } from '@app/components/toast/types';
|
||||
import CompareWorkerCtor from '@app/workers/compareWorker?worker';
|
||||
const LONG_RUNNING_PAGE_THRESHOLD = 2000;
|
||||
|
||||
export interface CompareOperationHook extends ToolOperationHook<CompareParameters> {
|
||||
result: CompareResultData | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// extractContentFromPdf moved to utils
|
||||
|
||||
export const useCompareOperation = (): CompareOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const { selectors } = useFileContext();
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const previousUrl = useRef<string | null>(null);
|
||||
const activeRunIdRef = useRef(0);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
type OperationStatus = 'idle' | 'extracting' | 'processing' | 'complete' | 'cancelled' | 'error';
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [statusState, setStatusState] = useState<OperationStatus>('idle');
|
||||
const [statusDetailMs, setStatusDetailMs] = useState<number | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadFilename, setDownloadFilename] = useState('');
|
||||
const [result, setResult] = useState<CompareResultData | null>(null);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
const longRunningToastIdRef = useRef<string | null>(null);
|
||||
const dissimilarityToastIdRef = useRef<string | null>(null);
|
||||
const dissimilarityToastShownRef = useRef<boolean>(false);
|
||||
|
||||
const ensureWorker = useCallback(() => {
|
||||
if (!workerRef.current) {
|
||||
workerRef.current = new CompareWorkerCtor();
|
||||
}
|
||||
return workerRef.current;
|
||||
}, []);
|
||||
|
||||
const cleanupDownloadUrl = useCallback(() => {
|
||||
if (previousUrl.current) {
|
||||
URL.revokeObjectURL(previousUrl.current);
|
||||
previousUrl.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
setResult(null);
|
||||
setWarnings([]);
|
||||
setFiles([]);
|
||||
cleanupDownloadUrl();
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
setStatusState('idle');
|
||||
setStatusDetailMs(null);
|
||||
setErrorMessage(null);
|
||||
}, [cleanupDownloadUrl]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
const runCompareWorker = useCallback(
|
||||
async (baseTokens: string[], comparisonTokens: string[], warningMessages: CompareWorkerWarnings, onChunk?: (chunk: CompareDiffToken[]) => void) => {
|
||||
const worker = ensureWorker();
|
||||
|
||||
return await new Promise<{
|
||||
tokens: CompareDiffToken[];
|
||||
stats: { baseWordCount: number; comparisonWordCount: number; durationMs: number };
|
||||
warnings: string[];
|
||||
}>((resolve, reject) => {
|
||||
const collectedWarnings: string[] = [];
|
||||
const collectedTokens: CompareDiffToken[] = [];
|
||||
|
||||
const handleMessage = (event: MessageEvent<CompareWorkerResponse>) => {
|
||||
if (cancelledRef.current) {
|
||||
cleanup();
|
||||
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
|
||||
return;
|
||||
}
|
||||
const message = event.data;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'chunk': {
|
||||
if (message.tokens.length > 0) {
|
||||
collectedTokens.push(...message.tokens);
|
||||
onChunk?.(message.tokens);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'success':
|
||||
cleanup();
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
resolve({
|
||||
tokens: collectedTokens,
|
||||
stats: message.stats,
|
||||
warnings: collectedWarnings,
|
||||
});
|
||||
break;
|
||||
case 'warning':
|
||||
collectedWarnings.push(message.message);
|
||||
break;
|
||||
case 'error': {
|
||||
cleanup();
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
const error: Error & { code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR' } = new Error(message.message);
|
||||
error.code = message.code;
|
||||
reject(error);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
cleanup();
|
||||
if (cancelledRef.current) {
|
||||
reject(Object.assign(new Error('Operation cancelled'), { code: 'CANCELLED' as const }));
|
||||
} else {
|
||||
reject(event.error ?? new Error(event.message));
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
worker.removeEventListener('message', handleMessage as EventListener);
|
||||
worker.removeEventListener('error', handleError as EventListener);
|
||||
};
|
||||
|
||||
worker.addEventListener('message', handleMessage as EventListener);
|
||||
worker.addEventListener('error', handleError as EventListener);
|
||||
|
||||
const request: CompareWorkerRequest = {
|
||||
type: 'compare',
|
||||
payload: {
|
||||
baseTokens,
|
||||
comparisonTokens,
|
||||
warnings: warningMessages,
|
||||
// Static worker settings to support large documents
|
||||
settings: {
|
||||
batchSize: 5000,
|
||||
complexThreshold: 120000,
|
||||
maxWordThreshold: 200000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
worker.postMessage(request);
|
||||
});
|
||||
},
|
||||
[ensureWorker]
|
||||
);
|
||||
|
||||
const executeOperation = useCallback(
|
||||
async (params: CompareParameters, selectedFiles: StirlingFile[]) => {
|
||||
// start new run
|
||||
const runId = ++activeRunIdRef.current;
|
||||
cancelledRef.current = false;
|
||||
if (!params.baseFileId || !params.comparisonFileId) {
|
||||
setErrorMessage(t('compare.error.selectRequired', 'Select the original and edited document.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const baseFile = selectedFiles.find((file) => file.fileId === params.baseFileId)
|
||||
?? selectors.getFile(params.baseFileId);
|
||||
const comparisonFile = selectedFiles.find((file) => file.fileId === params.comparisonFileId)
|
||||
?? selectors.getFile(params.comparisonFileId);
|
||||
|
||||
if (!baseFile || !comparisonFile) {
|
||||
setErrorMessage(t('compare.error.filesMissing', 'Unable to locate the selected files. Please re-select them.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setStatusState('extracting');
|
||||
setStatusDetailMs(null);
|
||||
setErrorMessage(null);
|
||||
setWarnings([]);
|
||||
setResult(null);
|
||||
setFiles([]);
|
||||
cleanupDownloadUrl();
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
|
||||
const warningMessages: CompareWorkerWarnings = {
|
||||
// No accuracy warning any more
|
||||
tooLargeMessage: t(
|
||||
'compare.large.file.message',
|
||||
'These documents are very large; comparison may take several minutes. Please keep this tab open.'
|
||||
),
|
||||
emptyTextMessage: t(
|
||||
'compare.no.text.message',
|
||||
'One or both of the selected PDFs have no text content. Please choose PDFs with text for comparison.'
|
||||
),
|
||||
tooDissimilarMessage: t(
|
||||
'compare.too.dissimilar.message',
|
||||
'These documents appear highly dissimilar. Comparison was stopped to save time.'
|
||||
),
|
||||
};
|
||||
|
||||
const operationStart = performance.now();
|
||||
|
||||
try {
|
||||
const [baseContent, comparisonContent] = await Promise.all([
|
||||
extractContentFromPdf(baseFile),
|
||||
extractContentFromPdf(comparisonFile),
|
||||
]);
|
||||
|
||||
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
|
||||
|
||||
if (baseContent.tokens.length === 0 || comparisonContent.tokens.length === 0) {
|
||||
throw Object.assign(new Error(warningMessages.emptyTextMessage), { code: 'EMPTY_TEXT' });
|
||||
}
|
||||
|
||||
setStatusState('processing');
|
||||
|
||||
// Filter out paragraph sentinels before diffing to avoid large false-positive runs
|
||||
const baseFiltered = filterTokensForDiff(baseContent.tokens, baseContent.metadata);
|
||||
const comparisonFiltered = filterTokensForDiff(comparisonContent.tokens, comparisonContent.metadata);
|
||||
|
||||
const combinedPageCount =
|
||||
(baseContent.pageSizes?.length ?? 0) + (comparisonContent.pageSizes?.length ?? 0);
|
||||
|
||||
if (
|
||||
combinedPageCount >= LONG_RUNNING_PAGE_THRESHOLD &&
|
||||
!longRunningToastIdRef.current
|
||||
) {
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: t('compare.longJob.title', 'Large comparison in progress'),
|
||||
body: t(
|
||||
'compare.longJob.body',
|
||||
'These PDFs together exceed 2,000 pages. Processing can take several minutes.'
|
||||
),
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
});
|
||||
longRunningToastIdRef.current = toastId || null;
|
||||
}
|
||||
|
||||
// Heuristic: surface an early warning toast when we observe a very high ratio of differences
|
||||
const EARLY_TOAST_MIN_TOKENS = 15000; // wait for some signal before warning
|
||||
const EARLY_TOAST_DIFF_RATIO = 0.8; // 80% added/removed vs unchanged
|
||||
let observedAddedRemoved = 0;
|
||||
let observedUnchanged = 0;
|
||||
|
||||
const handleEarlyDissimilarity = () => {
|
||||
if (dissimilarityToastShownRef.current || dissimilarityToastIdRef.current) return;
|
||||
const toastId = alert({
|
||||
alertType: 'warning',
|
||||
title: t('compare.earlyDissimilarity.title', 'These PDFs look highly different'),
|
||||
body: t(
|
||||
'compare.earlyDissimilarity.body',
|
||||
"We're seeing very few similarities so far. You can stop the comparison if these aren't related documents."
|
||||
),
|
||||
location: 'bottom-right' as ToastLocation,
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
buttonText: t('compare.earlyDissimilarity.stopButton', 'Stop comparison'),
|
||||
buttonCallback: () => {
|
||||
try { cancelOperation(); } catch {
|
||||
console.error('Failed to cancel operation');
|
||||
}
|
||||
try { window.dispatchEvent(new CustomEvent('compare:clear-selected')); } catch {
|
||||
console.error('Failed to dispatch clear selected event');
|
||||
}
|
||||
if (dissimilarityToastIdRef.current) {
|
||||
dismissToast(dissimilarityToastIdRef.current);
|
||||
dissimilarityToastIdRef.current = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
dissimilarityToastIdRef.current = toastId || null;
|
||||
dissimilarityToastShownRef.current = true;
|
||||
};
|
||||
|
||||
const { tokens, stats, warnings: workerWarnings } = await runCompareWorker(
|
||||
baseFiltered.tokens,
|
||||
comparisonFiltered.tokens,
|
||||
warningMessages,
|
||||
(chunk) => {
|
||||
// Incremental ratio tracking for early warning
|
||||
for (const tok of chunk) {
|
||||
if (tok.type === 'unchanged') observedUnchanged += 1;
|
||||
else observedAddedRemoved += 1;
|
||||
}
|
||||
const seen = observedAddedRemoved + observedUnchanged;
|
||||
if (
|
||||
!dissimilarityToastShownRef.current &&
|
||||
seen >= EARLY_TOAST_MIN_TOKENS &&
|
||||
observedAddedRemoved / Math.max(1, seen) >= EARLY_TOAST_DIFF_RATIO
|
||||
) {
|
||||
handleEarlyDissimilarity();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (cancelledRef.current || activeRunIdRef.current !== runId) return;
|
||||
|
||||
const baseHasHighlight = new Array<boolean>(baseFiltered.tokens.length).fill(false);
|
||||
const comparisonHasHighlight = new Array<boolean>(comparisonFiltered.tokens.length).fill(false);
|
||||
|
||||
let baseTokenPointer = 0;
|
||||
let comparisonTokenPointer = 0;
|
||||
for (const diffToken of tokens) {
|
||||
if (diffToken.type === 'removed') {
|
||||
if (baseTokenPointer < baseHasHighlight.length) {
|
||||
baseHasHighlight[baseTokenPointer] = true;
|
||||
}
|
||||
baseTokenPointer += 1;
|
||||
} else if (diffToken.type === 'added') {
|
||||
if (comparisonTokenPointer < comparisonHasHighlight.length) {
|
||||
comparisonHasHighlight[comparisonTokenPointer] = true;
|
||||
}
|
||||
comparisonTokenPointer += 1;
|
||||
} else {
|
||||
if (baseTokenPointer < baseHasHighlight.length) {
|
||||
baseTokenPointer += 1;
|
||||
}
|
||||
if (comparisonTokenPointer < comparisonHasHighlight.length) {
|
||||
comparisonTokenPointer += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildFilteredTokenData = (
|
||||
tokensList: typeof baseFiltered.tokens,
|
||||
metadataList: typeof baseFiltered.metadata,
|
||||
highlightFlags: boolean[]
|
||||
): CompareFilteredTokenInfo[] =>
|
||||
tokensList.map((token, index) => {
|
||||
const meta = metadataList[index];
|
||||
return {
|
||||
token,
|
||||
page: meta?.page ?? null,
|
||||
paragraph: meta?.paragraph ?? null,
|
||||
bbox: meta?.bbox ?? null,
|
||||
hasHighlight: highlightFlags[index] ?? false,
|
||||
metaIndex: index,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = aggregateTotals(tokens);
|
||||
const processedAt = Date.now();
|
||||
|
||||
const baseMetadata = baseFiltered.metadata;
|
||||
const comparisonMetadata = comparisonFiltered.metadata;
|
||||
|
||||
const changes = buildChanges(tokens, baseMetadata, comparisonMetadata);
|
||||
|
||||
const comparisonResult: CompareResultData = {
|
||||
base: {
|
||||
fileId: baseFile.fileId,
|
||||
fileName: baseFile.name,
|
||||
highlightColor: REMOVAL_HIGHLIGHT,
|
||||
wordCount: stats.baseWordCount,
|
||||
pageSizes: baseContent.pageSizes,
|
||||
},
|
||||
comparison: {
|
||||
fileId: comparisonFile.fileId,
|
||||
fileName: comparisonFile.name,
|
||||
highlightColor: ADDITION_HIGHLIGHT,
|
||||
wordCount: stats.comparisonWordCount,
|
||||
pageSizes: comparisonContent.pageSizes,
|
||||
},
|
||||
totals: {
|
||||
...totals,
|
||||
durationMs: stats.durationMs,
|
||||
processedAt,
|
||||
},
|
||||
tokens,
|
||||
tokenMetadata: {
|
||||
base: baseMetadata,
|
||||
comparison: comparisonMetadata,
|
||||
},
|
||||
filteredTokenData: {
|
||||
base: buildFilteredTokenData(baseFiltered.tokens, baseFiltered.metadata, baseHasHighlight),
|
||||
comparison: buildFilteredTokenData(
|
||||
comparisonFiltered.tokens,
|
||||
comparisonFiltered.metadata,
|
||||
comparisonHasHighlight
|
||||
),
|
||||
},
|
||||
sourceTokens: {
|
||||
base: baseContent.tokens,
|
||||
comparison: comparisonContent.tokens,
|
||||
},
|
||||
changes,
|
||||
warnings: workerWarnings,
|
||||
baseParagraphs: baseContent.paragraphs,
|
||||
comparisonParagraphs: comparisonContent.paragraphs,
|
||||
};
|
||||
|
||||
setResult(comparisonResult);
|
||||
setWarnings(workerWarnings);
|
||||
|
||||
const summaryFile = createSummaryFile(comparisonResult);
|
||||
setFiles([summaryFile]);
|
||||
|
||||
cleanupDownloadUrl();
|
||||
const blobUrl = URL.createObjectURL(summaryFile);
|
||||
previousUrl.current = blobUrl;
|
||||
setDownloadUrl(blobUrl);
|
||||
setDownloadFilename(summaryFile.name);
|
||||
|
||||
setStatusState('complete');
|
||||
} catch (error: unknown) {
|
||||
console.error('[compare] operation failed', error);
|
||||
const errorCode = getWorkerErrorCode(error);
|
||||
if (errorCode === 'EMPTY_TEXT') {
|
||||
setErrorMessage(warningMessages.emptyTextMessage ?? t('compare.error.generic', 'Unable to compare these files.'));
|
||||
} else {
|
||||
const fallbackMessage = t('compare.error.generic', 'Unable to compare these files.');
|
||||
if (error instanceof Error && error.message) {
|
||||
setErrorMessage(error.message);
|
||||
} else if (typeof error === 'string' && error.trim().length > 0) {
|
||||
setErrorMessage(error);
|
||||
} else {
|
||||
setErrorMessage(fallbackMessage);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
const duration = performance.now() - operationStart;
|
||||
setStatusDetailMs(Math.round(duration));
|
||||
setIsLoading(false);
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
if (dissimilarityToastIdRef.current) {
|
||||
dismissToast(dissimilarityToastIdRef.current);
|
||||
dissimilarityToastIdRef.current = null;
|
||||
}
|
||||
dissimilarityToastShownRef.current = false;
|
||||
}
|
||||
},
|
||||
[cleanupDownloadUrl, runCompareWorker, selectors, t]
|
||||
);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
if (!isLoading) return;
|
||||
cancelledRef.current = true;
|
||||
setIsLoading(false);
|
||||
setStatusState('cancelled');
|
||||
if (workerRef.current) {
|
||||
try {
|
||||
workerRef.current.terminate();
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
workerRef.current = null;
|
||||
}
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const undoOperation = useCallback(async () => {
|
||||
resetResults();
|
||||
}, [resetResults]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupDownloadUrl();
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
if (longRunningToastIdRef.current) {
|
||||
dismissToast(longRunningToastIdRef.current);
|
||||
longRunningToastIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [cleanupDownloadUrl]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
const label =
|
||||
statusState === 'idle' ? ''
|
||||
: statusState === 'extracting' ? t('compare.status.extracting', 'Extracting text...')
|
||||
: statusState === 'processing' ? t('compare.status.processing', 'Analyzing differences...')
|
||||
: statusState === 'complete' ? t('compare.status.complete', 'Comparison ready')
|
||||
: statusState === 'cancelled' ? t('operationCancelled', 'Operation cancelled')
|
||||
: '';
|
||||
if (label && statusDetailMs != null) return `${label} (${statusDetailMs} ms)`;
|
||||
return label;
|
||||
}, [statusState, statusDetailMs, t]);
|
||||
|
||||
return useMemo<CompareOperationHook>(
|
||||
() => ({
|
||||
files,
|
||||
thumbnails: [],
|
||||
isGeneratingThumbnails: false,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
isLoading,
|
||||
status,
|
||||
errorMessage,
|
||||
progress: null,
|
||||
executeOperation,
|
||||
resetResults,
|
||||
clearError,
|
||||
cancelOperation,
|
||||
undoOperation,
|
||||
result,
|
||||
warnings,
|
||||
}),
|
||||
[
|
||||
cancelOperation,
|
||||
clearError,
|
||||
downloadFilename,
|
||||
downloadUrl,
|
||||
errorMessage,
|
||||
executeOperation,
|
||||
files,
|
||||
isLoading,
|
||||
resetResults,
|
||||
result,
|
||||
status,
|
||||
undoOperation,
|
||||
warnings,
|
||||
]
|
||||
);
|
||||
};
|
||||
@ -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 { useFileActions } from '@app/contexts/FileContext';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
|
||||
export const useFileHandler = () => {
|
||||
const { actions } = useFileActions();
|
||||
|
||||
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}) => {
|
||||
const addFiles = useCallback(async (files: File[], options: { insertAfterPageId?: string; selectFiles?: boolean } = {}): Promise<StirlingFile[]> => {
|
||||
// Merge default options with passed options - passed options take precedence
|
||||
const mergedOptions = { selectFiles: true, ...options };
|
||||
// Let FileContext handle deduplication with quickKey logic
|
||||
await actions.addFiles(files, mergedOptions);
|
||||
const result = await actions.addFiles(files, mergedOptions);
|
||||
return result;
|
||||
}, [actions.addFiles]);
|
||||
|
||||
return {
|
||||
|
||||
9
frontend/src/core/hooks/useIsMobile.ts
Normal file
9
frontend/src/core/hooks/useIsMobile.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
|
||||
/**
|
||||
* Custom hook to detect mobile viewport
|
||||
* Uses a consistent breakpoint across the application
|
||||
*/
|
||||
export const useIsMobile = (): boolean => {
|
||||
return useMediaQuery('(max-width: 1024px)') ?? false;
|
||||
};
|
||||
279
frontend/src/core/hooks/useProgressivePagePreviews.ts
Normal file
279
frontend/src/core/hooks/useProgressivePagePreviews.ts
Normal file
@ -0,0 +1,279 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
||||
import { PagePreview } from '@app/types/compare';
|
||||
|
||||
const DISPLAY_SCALE = 1;
|
||||
const BATCH_SIZE = 10; // Render 10 pages at a time
|
||||
|
||||
const getDevicePixelRatio = () => (typeof window !== 'undefined' ? window.devicePixelRatio : 1);
|
||||
|
||||
interface ProgressivePagePreviewsOptions {
|
||||
file: File | null;
|
||||
enabled: boolean;
|
||||
cacheKey: number | null;
|
||||
visiblePageRange?: { start: number; end: number }; // 0-based page indices
|
||||
}
|
||||
|
||||
interface ProgressivePagePreviewsState {
|
||||
pages: PagePreview[];
|
||||
loading: boolean;
|
||||
totalPages: number;
|
||||
loadedPages: Set<number>; // 0-based page indices that have been loaded
|
||||
loadingPages: Set<number>; // 0-based page indices currently being loaded
|
||||
}
|
||||
|
||||
export const useProgressivePagePreviews = ({
|
||||
file,
|
||||
enabled,
|
||||
cacheKey,
|
||||
visiblePageRange,
|
||||
}: ProgressivePagePreviewsOptions) => {
|
||||
const [state, setState] = useState<ProgressivePagePreviewsState>({
|
||||
pages: [],
|
||||
loading: false,
|
||||
totalPages: 0,
|
||||
loadedPages: new Set(),
|
||||
loadingPages: new Set(),
|
||||
});
|
||||
|
||||
const pdfRef = useRef<PDFDocumentProxy | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const renderPageBatch = useCallback(async (
|
||||
pdf: PDFDocumentProxy,
|
||||
pageNumbers: number[],
|
||||
signal: AbortSignal
|
||||
): Promise<PagePreview[]> => {
|
||||
const previews: PagePreview[] = [];
|
||||
const dpr = getDevicePixelRatio();
|
||||
const renderScale = Math.max(2, Math.min(3, dpr * 2));
|
||||
|
||||
for (const pageNumber of pageNumbers) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const displayViewport = page.getViewport({ scale: DISPLAY_SCALE });
|
||||
const renderViewport = page.getViewport({ scale: renderScale });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
canvas.width = Math.round(renderViewport.width);
|
||||
canvas.height = Math.round(renderViewport.height);
|
||||
|
||||
if (!context) {
|
||||
page.cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport: renderViewport, canvas }).promise;
|
||||
previews.push({
|
||||
pageNumber,
|
||||
width: Math.round(displayViewport.width),
|
||||
height: Math.round(displayViewport.height),
|
||||
rotation: (page.rotate || 0) % 360,
|
||||
url: canvas.toDataURL(),
|
||||
});
|
||||
|
||||
page.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
} catch (error) {
|
||||
console.error(`[progressive-pages] failed to render page ${pageNumber}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return previews;
|
||||
}, []);
|
||||
|
||||
const loadPageRange = useCallback(async (
|
||||
startPage: number,
|
||||
endPage: number,
|
||||
signal: AbortSignal
|
||||
) => {
|
||||
// Use the live PDF ref for bounds instead of possibly stale state
|
||||
const totalPages = pdfRef.current?.numPages ?? state.totalPages;
|
||||
if (startPage < 0 || endPage >= totalPages || startPage > endPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which pages need to be loaded
|
||||
const pagesToLoad: number[] = [];
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
if (!state.loadedPages.has(i) && !state.loadingPages.has(i)) {
|
||||
pagesToLoad.push(i + 1); // Convert to 1-based page numbers
|
||||
}
|
||||
}
|
||||
|
||||
if (pagesToLoad.length === 0) return;
|
||||
|
||||
// Mark pages as loading
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loadingPages: new Set([...prev.loadingPages, ...pagesToLoad.map(p => p - 1)]),
|
||||
}));
|
||||
|
||||
try {
|
||||
const pdfDoc = pdfRef.current;
|
||||
if (!pdfDoc) return;
|
||||
const previews = await renderPageBatch(pdfDoc, pagesToLoad, signal);
|
||||
|
||||
if (!signal.aborted) {
|
||||
setState(prev => {
|
||||
const newPages = [...prev.pages];
|
||||
const newLoadedPages = new Set(prev.loadedPages);
|
||||
const newLoadingPages = new Set(prev.loadingPages);
|
||||
|
||||
// Add new previews and mark as loaded
|
||||
for (const preview of previews) {
|
||||
const pageIndex = preview.pageNumber - 1; // Convert to 0-based
|
||||
newLoadedPages.add(pageIndex);
|
||||
newLoadingPages.delete(pageIndex);
|
||||
|
||||
// Insert preview in correct position
|
||||
const insertIndex = newPages.findIndex(p => p.pageNumber > preview.pageNumber);
|
||||
if (insertIndex === -1) {
|
||||
newPages.push(preview);
|
||||
} else {
|
||||
newPages.splice(insertIndex, 0, preview);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
pages: newPages,
|
||||
loadedPages: newLoadedPages,
|
||||
loadingPages: newLoadingPages,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
console.error('[progressive-pages] failed to load page batch:', error);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setState(prev => {
|
||||
const newLoadingPages = new Set(prev.loadingPages);
|
||||
pagesToLoad.forEach(p => newLoadingPages.delete(p - 1));
|
||||
return { ...prev, loadingPages: newLoadingPages };
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [state.loadedPages, state.loadingPages, state.totalPages, renderPageBatch]);
|
||||
|
||||
// Initialize PDF and load first batch
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!file || !enabled) {
|
||||
setState({
|
||||
pages: [],
|
||||
loading: false,
|
||||
totalPages: 0,
|
||||
loadedPages: new Set(),
|
||||
loadingPages: new Set(),
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const initialize = async () => {
|
||||
try {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
return;
|
||||
}
|
||||
|
||||
pdfRef.current = pdf;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
totalPages,
|
||||
loading: false,
|
||||
}));
|
||||
|
||||
// Load first batch of pages using a real abort controller
|
||||
const initAbort = new AbortController();
|
||||
const firstBatchEnd = Math.min(BATCH_SIZE - 1, totalPages - 1);
|
||||
await loadPageRange(0, firstBatchEnd, initAbort.signal);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[progressive-pages] failed to initialize PDF:', error);
|
||||
if (!cancelled) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
totalPages: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pdfRef.current) {
|
||||
pdfWorkerManager.destroyDocument(pdfRef.current);
|
||||
pdfRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [file, enabled, cacheKey, loadPageRange]);
|
||||
|
||||
// Load pages based on visible range
|
||||
useEffect(() => {
|
||||
if (!visiblePageRange || state.totalPages === 0) return;
|
||||
|
||||
const { start, end } = visiblePageRange;
|
||||
const startPage = Math.max(0, start - 5); // Add buffer before
|
||||
const endPage = Math.min(state.totalPages - 1, end + 5); // Add buffer after
|
||||
|
||||
// Cancel previous loading
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
loadPageRange(startPage, endPage, abortController.signal);
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [visiblePageRange, state.totalPages, loadPageRange]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
if (pdfRef.current) {
|
||||
pdfWorkerManager.destroyDocument(pdfRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pages: state.pages,
|
||||
loading: state.loading,
|
||||
totalPages: state.totalPages,
|
||||
loadedPages: state.loadedPages,
|
||||
loadingPages: state.loadingPages,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseProgressivePagePreviewsReturn = ReturnType<typeof useProgressivePagePreviews>;
|
||||
@ -1,9 +1,9 @@
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
export function useShouldShowWelcomeModal(): boolean {
|
||||
const { preferences } = usePreferences();
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return !preferences.hasCompletedOnboarding
|
||||
&& preferences.toolPanelModePromptSeen
|
||||
|
||||
@ -6,7 +6,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { useBaseUrl } from "@app/hooks/useBaseUrl";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
|
||||
@ -45,7 +45,7 @@ export default function HomePage() {
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { config } = useAppConfig();
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const isMobile = useIsMobile();
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
|
||||
@ -1,3 +1,19 @@
|
||||
:root {
|
||||
/* Compare highlight colors (same in light/dark) */
|
||||
--spdf-compare-removed-bg: rgba(255, 107, 107, 0.45); /* #ff6b6b @ 0.45 */
|
||||
--spdf-compare-added-bg: rgba(81, 207, 102, 0.35); /* #51cf66 @ 0.35 */
|
||||
|
||||
/* Badge colors for dropdowns */
|
||||
--spdf-compare-removed-badge-bg: rgba(255, 59, 48, 0.15);
|
||||
--spdf-compare-removed-badge-fg: #b91c1c;
|
||||
--spdf-compare-added-badge-bg: rgba(52, 199, 89, 0.18);
|
||||
--spdf-compare-added-badge-fg: #1b5e20;
|
||||
|
||||
/* Inline highlights in summary */
|
||||
--spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25);
|
||||
--spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25);
|
||||
}
|
||||
|
||||
/* CSS variables for Tailwind + Mantine integration */
|
||||
|
||||
:root {
|
||||
@ -174,6 +190,7 @@
|
||||
--right-rail-foreground: #E3E4E5; /* panel behind custom tool icons */
|
||||
--right-rail-icon: #4B5563; /* icon color */
|
||||
--right-rail-icon-disabled: #CECECE;/* disabled icon */
|
||||
--right-rail-pan-active-bg: #EAEAEA;
|
||||
|
||||
/* Colors for tooltips */
|
||||
--tooltip-title-bg: #DBEFFF;
|
||||
@ -284,6 +301,17 @@
|
||||
--pdf-light-report-container-bg: 249 250 251;
|
||||
--pdf-light-simulated-page-bg: 255 255 255;
|
||||
--pdf-light-simulated-page-text: 15 23 42;
|
||||
|
||||
/* Compare tool specific colors - only for colors that don't have existing theme pairs */
|
||||
--compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45);
|
||||
--compare-upload-dropzone-border: rgba(148, 163, 184, 0.6);
|
||||
--compare-upload-icon-bg: rgba(148, 163, 184, 0.2);
|
||||
--compare-upload-icon-color: rgba(17, 24, 39, 0.75);
|
||||
--compare-upload-divider: rgba(148, 163, 184, 0.5);
|
||||
|
||||
/* Compare page label chip (light mode): slightly lighter than surrounding rows */
|
||||
--compare-page-label-bg: var(--bg-muted);
|
||||
--compare-page-label-fg: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
@ -415,6 +443,7 @@
|
||||
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
|
||||
--right-rail-icon: #BCBEBF; /* icon color */
|
||||
--right-rail-icon-disabled: #43464B;/* disabled icon */
|
||||
--right-rail-pan-active-bg: #EAEAEA;
|
||||
|
||||
/* Dark mode tooltip colors */
|
||||
--tooltip-title-bg: #4B525A;
|
||||
@ -428,6 +457,10 @@
|
||||
--text-brand: var(--color-gray-800);
|
||||
--text-brand-accent: #EF4444;
|
||||
|
||||
/* Compare badge text colors (dark mode): lighter for readability */
|
||||
--spdf-compare-removed-badge-fg: var(--color-red-500);
|
||||
--spdf-compare-added-badge-fg: var(--color-green-500);
|
||||
|
||||
/* container */
|
||||
--landing-paper-bg: #171A1F;
|
||||
--landing-inner-paper-bg: var(--bg-raised);
|
||||
@ -500,6 +533,17 @@
|
||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
|
||||
--modal-content-bg: #2A2F36;
|
||||
--modal-header-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
/* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */
|
||||
--compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45);
|
||||
--compare-upload-dropzone-border: rgba(75, 85, 99, 0.6);
|
||||
--compare-upload-icon-bg: rgba(75, 85, 99, 0.2);
|
||||
--compare-upload-icon-color: rgba(243, 244, 246, 0.75);
|
||||
--compare-upload-divider: rgba(75, 85, 99, 0.5);
|
||||
|
||||
/* Compare page label chip (dark mode): slightly darker than surrounding rows */
|
||||
--compare-page-label-bg: #1F2329;
|
||||
--compare-page-label-fg: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Dropzone drop state styling */
|
||||
|
||||
596
frontend/src/core/tools/Compare.tsx
Normal file
596
frontend/src/core/tools/Compare.tsx
Normal file
@ -0,0 +1,596 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CompareRoundedIcon from '@mui/icons-material/CompareRounded';
|
||||
import { Box, Group, Stack, Text, Button, Modal, ActionIcon } from '@mantine/core';
|
||||
import SwapVertRoundedIcon from '@mui/icons-material/SwapVertRounded';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
|
||||
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
|
||||
import { BaseToolProps, ToolComponent } from '@app/types/tool';
|
||||
import {
|
||||
useCompareParameters,
|
||||
defaultParameters as compareDefaultParameters,
|
||||
} from '@app/hooks/tools/compare/useCompareParameters';
|
||||
import {
|
||||
useCompareOperation,
|
||||
CompareOperationHook,
|
||||
} from '@app/hooks/tools/compare/useCompareOperation';
|
||||
import CompareWorkbenchView from '@app/components/tools/compare/CompareWorkbenchView';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useFileContext, useFileState } from '@app/contexts/file/fileHooks';
|
||||
import type { FileId } from '@app/types/file';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
|
||||
import type { CompareWorkbenchData } from '@app/types/compare';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
import { getDefaultWorkbench } from '@app/types/workbench';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
|
||||
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
|
||||
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
|
||||
|
||||
const Compare = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
const { selectors, actions: fileActions } = useFileContext();
|
||||
const { state: fileState } = useFileState();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
const base = useBaseTool(
|
||||
'compare',
|
||||
useCompareParameters,
|
||||
useCompareOperation,
|
||||
props,
|
||||
{ minFiles: 2 }
|
||||
);
|
||||
|
||||
const operation = base.operation as CompareOperationHook;
|
||||
const params = base.params.parameters;
|
||||
|
||||
const compareIcon = useMemo(() => <CompareRoundedIcon fontSize="small" />, []);
|
||||
const [swapConfirmOpen, setSwapConfirmOpen] = useState(false);
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
const performClearSelected = useCallback(() => {
|
||||
try { base.operation.cancelOperation(); } catch { console.error('Failed to cancel operation'); }
|
||||
try { base.operation.resetResults(); } catch { console.error('Failed to reset results'); }
|
||||
base.params.setParameters(prev => ({ ...prev, baseFileId: null, comparisonFileId: null }));
|
||||
try { fileActions.clearSelections(); } catch { console.error('Failed to clear selections'); }
|
||||
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
|
||||
navigationActions.setWorkbench(getDefaultWorkbench());
|
||||
}, [base.operation, base.params, clearCustomWorkbenchViewData, fileActions, navigationActions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
performClearSelected();
|
||||
};
|
||||
window.addEventListener('compare:clear-selected', handler as unknown as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('compare:clear-selected', handler as unknown as EventListener);
|
||||
};
|
||||
}, [performClearSelected]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: CUSTOM_VIEW_ID,
|
||||
workbenchId: CUSTOM_WORKBENCH_ID,
|
||||
// Use a static label at registration time to avoid re-registering on i18n changes
|
||||
label: 'Compare view',
|
||||
icon: compareIcon,
|
||||
component: CompareWorkbenchView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregisterCustomWorkbenchView(CUSTOM_VIEW_ID);
|
||||
};
|
||||
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
|
||||
}, []);
|
||||
|
||||
// Auto-map from workbench selection: always reflect the first two selected files in order.
|
||||
// This also handles deselection by promoting the remaining selection to base and clearing comparison.
|
||||
useEffect(() => {
|
||||
// Use selected IDs directly from state so it works even if File objects aren't loaded yet
|
||||
const selectedIds = (fileState.ui.selectedFileIds as FileId[]) ?? [];
|
||||
|
||||
// Determine next base: keep current if still selected; otherwise use the first selected id
|
||||
const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId)
|
||||
? (params.baseFileId as FileId)
|
||||
: (selectedIds[0] ?? null);
|
||||
|
||||
// Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id
|
||||
let nextComp: FileId | null = null;
|
||||
if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) {
|
||||
nextComp = params.comparisonFileId as FileId;
|
||||
} else {
|
||||
nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null;
|
||||
}
|
||||
|
||||
if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) {
|
||||
base.params.setParameters(prev => ({
|
||||
...prev,
|
||||
baseFileId: nextBase,
|
||||
comparisonFileId: nextComp,
|
||||
}));
|
||||
}
|
||||
}, [fileState.ui.selectedFileIds, base.params, params.baseFileId, params.comparisonFileId]);
|
||||
|
||||
// Track workbench data and drive loading/result state transitions
|
||||
const lastProcessedAtRef = useRef<number | null>(null);
|
||||
const lastWorkbenchDataRef = useRef<CompareWorkbenchData | null>(null);
|
||||
|
||||
const updateWorkbenchData = useCallback(
|
||||
(data: CompareWorkbenchData) => {
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
if (
|
||||
previous &&
|
||||
previous.result === data.result &&
|
||||
previous.baseFileId === data.baseFileId &&
|
||||
previous.comparisonFileId === data.comparisonFileId &&
|
||||
previous.isLoading === data.isLoading &&
|
||||
previous.baseLocalFile === data.baseLocalFile &&
|
||||
previous.comparisonLocalFile === data.comparisonLocalFile
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastWorkbenchDataRef.current = data;
|
||||
setCustomWorkbenchViewData(CUSTOM_VIEW_ID, data);
|
||||
},
|
||||
[setCustomWorkbenchViewData]
|
||||
);
|
||||
|
||||
const prepareWorkbenchForRun = useCallback(
|
||||
(
|
||||
baseId: FileId | null,
|
||||
compId: FileId | null,
|
||||
options?: { baseFile?: StirlingFile | null; comparisonFile?: StirlingFile | null }
|
||||
) => {
|
||||
if (!baseId || !compId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
const resolvedBaseFile =
|
||||
options?.baseFile ??
|
||||
(baseId ? selectors.getFile(baseId) : null) ??
|
||||
previous?.baseLocalFile ??
|
||||
null;
|
||||
const resolvedComparisonFile =
|
||||
options?.comparisonFile ??
|
||||
(compId ? selectors.getFile(compId) : null) ??
|
||||
previous?.comparisonLocalFile ??
|
||||
null;
|
||||
|
||||
updateWorkbenchData({
|
||||
result: null,
|
||||
baseFileId: baseId,
|
||||
comparisonFileId: compId,
|
||||
baseLocalFile: resolvedBaseFile,
|
||||
comparisonLocalFile: resolvedComparisonFile,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
lastProcessedAtRef.current = null;
|
||||
},
|
||||
[selectors, updateWorkbenchData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const baseFileId = params.baseFileId as FileId | null;
|
||||
const comparisonFileId = params.comparisonFileId as FileId | null;
|
||||
|
||||
if (!baseFileId || !comparisonFileId) {
|
||||
lastProcessedAtRef.current = null;
|
||||
lastWorkbenchDataRef.current = null;
|
||||
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = operation.result;
|
||||
const processedAt = result?.totals.processedAt ?? null;
|
||||
|
||||
if (
|
||||
result &&
|
||||
processedAt !== null &&
|
||||
processedAt !== lastProcessedAtRef.current &&
|
||||
result.base.fileId === baseFileId &&
|
||||
result.comparison.fileId === comparisonFileId
|
||||
) {
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
const baseLocalFile =
|
||||
(baseFileId ? selectors.getFile(baseFileId) : null) ??
|
||||
previous?.baseLocalFile ??
|
||||
null;
|
||||
const comparisonLocalFile =
|
||||
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
|
||||
previous?.comparisonLocalFile ??
|
||||
null;
|
||||
updateWorkbenchData({
|
||||
result,
|
||||
baseFileId,
|
||||
comparisonFileId,
|
||||
baseLocalFile,
|
||||
comparisonLocalFile,
|
||||
isLoading: false,
|
||||
});
|
||||
lastProcessedAtRef.current = processedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
if (base.operation.isLoading) {
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
const baseLocalFile =
|
||||
(baseFileId ? selectors.getFile(baseFileId) : null) ??
|
||||
previous?.baseLocalFile ??
|
||||
null;
|
||||
const comparisonLocalFile =
|
||||
(comparisonFileId ? selectors.getFile(comparisonFileId) : null) ??
|
||||
previous?.comparisonLocalFile ??
|
||||
null;
|
||||
updateWorkbenchData({
|
||||
result: null,
|
||||
baseFileId,
|
||||
comparisonFileId,
|
||||
baseLocalFile,
|
||||
comparisonLocalFile,
|
||||
isLoading: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
base.operation.isLoading,
|
||||
clearCustomWorkbenchViewData,
|
||||
operation.result,
|
||||
params.baseFileId,
|
||||
params.comparisonFileId,
|
||||
selectors,
|
||||
updateWorkbenchData,
|
||||
]);
|
||||
|
||||
const handleExecuteCompare = useCallback(async () => {
|
||||
const baseId = params.baseFileId as FileId | null;
|
||||
const compId = params.comparisonFileId as FileId | null;
|
||||
const baseSel =
|
||||
base.selectedFiles.find((file) => file.fileId === baseId) ??
|
||||
(baseId ? selectors.getFile(baseId) : null);
|
||||
const compSel =
|
||||
base.selectedFiles.find((file) => file.fileId === compId) ??
|
||||
(compId ? selectors.getFile(compId) : null);
|
||||
const selected: StirlingFile[] = [];
|
||||
if (baseSel) selected.push(baseSel);
|
||||
if (compSel) selected.push(compSel);
|
||||
|
||||
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
|
||||
if (baseId && compId) {
|
||||
requestAnimationFrame(() => {
|
||||
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
|
||||
});
|
||||
}
|
||||
|
||||
await operation.executeOperation(
|
||||
{ ...params },
|
||||
selected
|
||||
);
|
||||
}, [base.selectedFiles, navigationActions, operation, params, prepareWorkbenchForRun, selectors]);
|
||||
|
||||
// Run compare with explicit ids (used after swap so we don't depend on async state propagation)
|
||||
const runCompareWithIds = useCallback(async (baseId: FileId | null, compId: FileId | null) => {
|
||||
const nextParams = { ...params, baseFileId: baseId, comparisonFileId: compId };
|
||||
const selected: StirlingFile[] = [];
|
||||
const baseSel =
|
||||
base.selectedFiles.find((file) => file.fileId === baseId) ??
|
||||
(baseId ? selectors.getFile(baseId) : null);
|
||||
const compSel =
|
||||
base.selectedFiles.find((file) => file.fileId === compId) ??
|
||||
(compId ? selectors.getFile(compId) : null);
|
||||
if (baseSel) selected.push(baseSel);
|
||||
if (compSel) selected.push(compSel);
|
||||
prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null });
|
||||
await operation.executeOperation(nextParams, selected);
|
||||
}, [base.selectedFiles, operation, params, prepareWorkbenchForRun, selectors]);
|
||||
|
||||
const performSwap = useCallback(() => {
|
||||
const baseId = params.baseFileId as FileId | null;
|
||||
const compId = params.comparisonFileId as FileId | null;
|
||||
if (!baseId || !compId) return;
|
||||
base.params.setParameters((prev) => ({
|
||||
...prev,
|
||||
baseFileId: compId,
|
||||
comparisonFileId: baseId,
|
||||
}));
|
||||
if (operation.result) {
|
||||
runCompareWithIds(compId, baseId);
|
||||
}
|
||||
}, [base.params, operation.result, params.baseFileId, params.comparisonFileId, runCompareWithIds]);
|
||||
|
||||
// No custom handler; rely on global add flow which auto-selects added files
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
const baseId = params.baseFileId as FileId | null;
|
||||
const compId = params.comparisonFileId as FileId | null;
|
||||
if (!baseId || !compId) return;
|
||||
if (operation.result) {
|
||||
setSwapConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
performSwap();
|
||||
}, [operation.result, params.baseFileId, params.comparisonFileId, performSwap]);
|
||||
|
||||
const renderSelectedFile = useCallback(
|
||||
(role: 'base' | 'comparison') => {
|
||||
const fileId = role === 'base' ? params.baseFileId : params.comparisonFileId;
|
||||
const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined;
|
||||
|
||||
// Show add button in base if no base file, or in comparison if base exists but no comparison
|
||||
const shouldShowAddButton =
|
||||
(role === 'base' && !params.baseFileId) ||
|
||||
(role === 'comparison' && params.baseFileId && !params.comparisonFileId);
|
||||
|
||||
if (!stub) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Box
|
||||
style={{
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg-surface)',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: shouldShowAddButton ? 'pointer' : 'default',
|
||||
}}
|
||||
onClick={shouldShowAddButton ? () => openFilesModal({}) : undefined}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
role === 'base' ? 'compare.original.placeholder' : 'compare.edited.placeholder',
|
||||
role === 'base' ? 'Select the original PDF' : 'Select the edited PDF'
|
||||
)}
|
||||
</Text>
|
||||
{shouldShowAddButton && (
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal({});
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
// Build compact meta line for pages and date
|
||||
const dateMs = (stub?.lastModified || stub?.createdAt) ?? null;
|
||||
const dateText = dateMs
|
||||
? new Date(dateMs).toLocaleDateString(undefined, { month: 'short', day: '2-digit', year: 'numeric' })
|
||||
: '';
|
||||
const pageCount = stub?.processedFile?.totalPages || null;
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Box
|
||||
style={{
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg-surface)',
|
||||
width: '100%',
|
||||
minHeight: "9rem"
|
||||
|
||||
}}
|
||||
>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md">
|
||||
<Box className="compare-tool__thumbnail" style={{ alignSelf: 'center' }}>
|
||||
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
|
||||
</Box>
|
||||
<Stack className="compare-tool__details">
|
||||
<FitText
|
||||
text={stub?.name || ''}
|
||||
minimumFontScale={0.8}
|
||||
lines={3}
|
||||
style={{ fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
{pageCount && dateText && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{pageCount} {t('compare.pages', 'pages')}
|
||||
<br />
|
||||
{dateText}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
[params.baseFileId, params.comparisonFileId, selectors, t, openFilesModal]
|
||||
);
|
||||
|
||||
const baseStub = params.baseFileId ? selectors.getStirlingFileStub(params.baseFileId) : undefined;
|
||||
const compStub = params.comparisonFileId ? selectors.getStirlingFileStub(params.comparisonFileId) : undefined;
|
||||
const canExecute = Boolean(
|
||||
params.baseFileId &&
|
||||
params.comparisonFileId &&
|
||||
params.baseFileId !== params.comparisonFileId &&
|
||||
baseStub &&
|
||||
compStub &&
|
||||
!base.operation.isLoading &&
|
||||
base.endpointEnabled !== false
|
||||
);
|
||||
|
||||
const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId);
|
||||
const hasAnyFiles = selectors.getFiles().length > 0;
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t('compare.selection.originalEditedTitle', 'Select Original and Edited PDFs'),
|
||||
isVisible: true,
|
||||
content: (
|
||||
<Box
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: hasBothSelected ? '1fr 2.25rem' : '1fr',
|
||||
gap: '1rem',
|
||||
alignItems: 'stretch',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Header row: Original PDF + Clear selected aligned to swap column */}
|
||||
<Box
|
||||
style={{ gridColumn: hasBothSelected ? '1 / span 2' : '1', display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '0.5rem' }}
|
||||
>
|
||||
<Text fw={700} size="sm">{t('compare.original.label', 'Original PDF')}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => setClearConfirmOpen(true)}
|
||||
disabled={!hasAnyFiles}
|
||||
styles={{ root: { textDecoration: 'underline' } }}
|
||||
style={{
|
||||
background: !hasAnyFiles ? 'transparent' : undefined,
|
||||
color: !hasAnyFiles ? 'var(--spdf-clear-disabled-text)' : undefined
|
||||
}}
|
||||
>
|
||||
{t('compare.clearSelected', 'Clear selected')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
gridColumn: '1',
|
||||
minWidth: 0,
|
||||
|
||||
}}
|
||||
>
|
||||
{renderSelectedFile('base')}
|
||||
<div style={{ height: '0.75rem' }} />
|
||||
{/* Edited PDF section header */}
|
||||
<Text fw={700} size="sm" style={{ marginBottom: '1rem', marginTop: '0.5rem'}}>{t('compare.edited.label', 'Edited PDF')}</Text>
|
||||
{renderSelectedFile('comparison')}
|
||||
</Box>
|
||||
{hasBothSelected && (
|
||||
<Box
|
||||
style={{
|
||||
gridColumn: '2',
|
||||
gridRow: '2',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'stretch',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleSwap}
|
||||
disabled={!hasBothSelected || base.operation.isLoading}
|
||||
style={{
|
||||
width: '2.25rem',
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
borderRadius: '0.5rem',
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-default)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SwapVertRoundedIcon fontSize="medium" />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Modal
|
||||
opened={swapConfirmOpen}
|
||||
onClose={() => setSwapConfirmOpen(false)}
|
||||
title={t('compare.swap.confirmTitle', 'Re-run comparison?')}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>{t('compare.swap.confirmBody', 'This will rerun the tool. Are you sure you want to swap the order of Original and Edited?')}</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={() => setSwapConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => {
|
||||
setSwapConfirmOpen(false);
|
||||
performSwap();
|
||||
}}
|
||||
>
|
||||
{t('compare.swap.confirm', 'Swap and Re-run')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={clearConfirmOpen}
|
||||
onClose={() => setClearConfirmOpen(false)}
|
||||
title={t('compare.clear.confirmTitle', 'Clear selected PDFs?')}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>{t('compare.clear.confirmBody', 'This will close the current comparison and take you back to Active Files.')}</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={() => setClearConfirmOpen(false)}>{t('cancel', 'Cancel')}</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => {
|
||||
setClearConfirmOpen(false);
|
||||
performClearSelected();
|
||||
}}
|
||||
>
|
||||
{t('compare.clear.confirm', 'Clear and return')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t('compare.cta', 'Compare'),
|
||||
loadingText: t('compare.loading', 'Comparing...'),
|
||||
onClick: handleExecuteCompare,
|
||||
disabled: !canExecute,
|
||||
testId: 'compare-execute',
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: t('compare.review.title', 'Comparison Result'),
|
||||
onUndo: base.operation.undoOperation,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const CompareTool = Compare as ToolComponent;
|
||||
CompareTool.tool = () => useCompareOperation;
|
||||
CompareTool.getDefaultParameters = () => ({ ...compareDefaultParameters });
|
||||
|
||||
export default CompareTool;
|
||||
|
||||
|
||||
|
||||
@ -39,6 +39,8 @@ const ValidateSignature = (props: BaseToolProps) => {
|
||||
const hasResults = operation.results.length > 0;
|
||||
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: REPORT_VIEW_ID,
|
||||
|
||||
314
frontend/src/core/types/compare.ts
Normal file
314
frontend/src/core/types/compare.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import type { FileId } from '@app/types/file';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
|
||||
export type CompareDiffTokenType = 'unchanged' | 'removed' | 'added';
|
||||
|
||||
export interface CompareDiffToken {
|
||||
type: CompareDiffTokenType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const REMOVAL_HIGHLIGHT = '#FF3B30';
|
||||
export const ADDITION_HIGHLIGHT = '#34C759';
|
||||
export const PARAGRAPH_SENTINEL = '\uE000¶';
|
||||
|
||||
export interface TokenBoundingBox {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CompareTokenMetadata {
|
||||
page: number;
|
||||
paragraph: number;
|
||||
bbox: TokenBoundingBox | null;
|
||||
}
|
||||
|
||||
export interface ComparePageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CompareDocumentInfo {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
highlightColor: string;
|
||||
wordCount: number;
|
||||
pageSizes: ComparePageSize[];
|
||||
}
|
||||
|
||||
export interface CompareParagraph {
|
||||
page: number;
|
||||
paragraph: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CompareFilteredTokenInfo {
|
||||
token: string;
|
||||
page: number | null;
|
||||
paragraph: number | null;
|
||||
bbox: TokenBoundingBox | null;
|
||||
hasHighlight: boolean;
|
||||
metaIndex: number;
|
||||
}
|
||||
|
||||
export interface CompareChangeSide {
|
||||
text: string;
|
||||
page: number | null;
|
||||
paragraph: number | null;
|
||||
}
|
||||
|
||||
export interface CompareChange {
|
||||
id: string;
|
||||
base: CompareChangeSide | null;
|
||||
comparison: CompareChangeSide | null;
|
||||
}
|
||||
|
||||
export interface CompareResultData {
|
||||
base: CompareDocumentInfo;
|
||||
comparison: CompareDocumentInfo;
|
||||
totals: {
|
||||
added: number;
|
||||
removed: number;
|
||||
unchanged: number;
|
||||
durationMs: number;
|
||||
processedAt: number;
|
||||
};
|
||||
tokens: CompareDiffToken[];
|
||||
tokenMetadata: {
|
||||
base: CompareTokenMetadata[];
|
||||
comparison: CompareTokenMetadata[];
|
||||
};
|
||||
filteredTokenData: {
|
||||
base: CompareFilteredTokenInfo[];
|
||||
comparison: CompareFilteredTokenInfo[];
|
||||
};
|
||||
sourceTokens: {
|
||||
base: string[];
|
||||
comparison: string[];
|
||||
};
|
||||
changes: CompareChange[];
|
||||
warnings: string[];
|
||||
baseParagraphs: CompareParagraph[];
|
||||
comparisonParagraphs: CompareParagraph[];
|
||||
}
|
||||
|
||||
export interface CompareWorkerWarnings {
|
||||
complexMessage?: string;
|
||||
tooLargeMessage?: string;
|
||||
emptyTextMessage?: string;
|
||||
tooDissimilarMessage?: string;
|
||||
}
|
||||
|
||||
export interface CompareWorkerRequest {
|
||||
type: 'compare';
|
||||
payload: {
|
||||
baseTokens: string[];
|
||||
comparisonTokens: string[];
|
||||
warnings: CompareWorkerWarnings;
|
||||
settings?: {
|
||||
batchSize?: number;
|
||||
complexThreshold?: number;
|
||||
maxWordThreshold?: number;
|
||||
// Early-stop and runtime controls (optional)
|
||||
earlyStopEnabled?: boolean;
|
||||
minJaccardUnigram?: number;
|
||||
minJaccardBigram?: number;
|
||||
minTokensForEarlyStop?: number;
|
||||
sampleLimit?: number;
|
||||
runtimeMaxProcessedTokens?: number;
|
||||
runtimeMinUnchangedRatio?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type CompareWorkerResponse =
|
||||
| {
|
||||
type: 'chunk';
|
||||
tokens: CompareDiffToken[];
|
||||
}
|
||||
| {
|
||||
type: 'success';
|
||||
stats: {
|
||||
baseWordCount: number;
|
||||
comparisonWordCount: number;
|
||||
durationMs: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'warning';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
message: string;
|
||||
code?: 'EMPTY_TEXT' | 'TOO_LARGE' | 'TOO_DISSIMILAR';
|
||||
};
|
||||
|
||||
export interface CompareDocumentPaneProps {
|
||||
pane: 'base' | 'comparison';
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
peerScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
|
||||
handleWheelZoom: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
|
||||
handleWheelOverscroll: (pane: 'base' | 'comparison', event: React.WheelEvent<HTMLDivElement>) => void;
|
||||
onTouchStart: (pane: 'base' | 'comparison', event: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onTouchEnd: (event: React.TouchEvent<HTMLDivElement>) => void;
|
||||
isPanMode: boolean;
|
||||
zoom: number;
|
||||
title: string;
|
||||
dropdownPlaceholder?: React.ReactNode;
|
||||
changes: Array<{ value: string; label: string; pageNumber?: number }>;
|
||||
onNavigateChange: (id: string, pageNumber?: number) => void;
|
||||
isLoading: boolean;
|
||||
processingMessage: string;
|
||||
pages: PagePreview[];
|
||||
pairedPages: PagePreview[];
|
||||
getRowHeightPx: (pageNumber: number) => number;
|
||||
wordHighlightMap: Map<number, WordHighlightEntry[]>;
|
||||
metaIndexToGroupId: Map<number, string>;
|
||||
documentLabel: string;
|
||||
pageLabel: string;
|
||||
altLabel: string;
|
||||
// Page input/navigation props (optional to keep call sites flexible)
|
||||
pageInputValue?: string;
|
||||
onPageInputChange?: (next: string) => void;
|
||||
maxSharedPages?: number; // min(baseTotal, compTotal)
|
||||
renderedPageNumbers?: Set<number>;
|
||||
onVisiblePageChange?: (pane: 'base' | 'comparison', pageNumber: number) => void;
|
||||
}
|
||||
|
||||
// Import types that are referenced in CompareDocumentPaneProps
|
||||
export interface PagePreview {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface WordHighlightEntry {
|
||||
rect: TokenBoundingBox;
|
||||
metaIndex: number;
|
||||
}
|
||||
|
||||
export interface NavigationDropdownProps {
|
||||
changes: Array<{ value: string; label: string; pageNumber?: number }>;
|
||||
placeholder: React.ReactNode;
|
||||
className?: string;
|
||||
onNavigate: (value: string, pageNumber?: number) => void;
|
||||
// Optional: pages that currently have previews rendered (1-based page numbers)
|
||||
renderedPageNumbers?: Set<number>;
|
||||
}
|
||||
|
||||
// Pan/Zoom and Compare Workbench shared types (moved out of hooks for reuse)
|
||||
import type React from 'react';
|
||||
|
||||
export type ComparePane = 'base' | 'comparison';
|
||||
|
||||
export interface PanState {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ScrollLinkDelta {
|
||||
vertical: number;
|
||||
horizontal: number;
|
||||
}
|
||||
|
||||
export interface ScrollLinkAnchors {
|
||||
deltaPixelsBaseToComp: number;
|
||||
deltaPixelsCompToBase: number;
|
||||
}
|
||||
|
||||
export interface PanDragState {
|
||||
active: boolean;
|
||||
source: ComparePane | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startPanX: number;
|
||||
startPanY: number;
|
||||
targetStartPanX: number;
|
||||
targetStartPanY: number;
|
||||
}
|
||||
|
||||
export interface PinchState {
|
||||
active: boolean;
|
||||
pane: ComparePane | null;
|
||||
startDistance: number;
|
||||
startZoom: number;
|
||||
}
|
||||
|
||||
export interface UseComparePanZoomOptions {
|
||||
prefersStacked: boolean;
|
||||
basePages: PagePreview[];
|
||||
comparisonPages: PagePreview[];
|
||||
}
|
||||
|
||||
export interface UseComparePanZoomReturn {
|
||||
layout: 'side-by-side' | 'stacked';
|
||||
setLayout: (layout: 'side-by-side' | 'stacked') => void;
|
||||
toggleLayout: () => void;
|
||||
baseScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
comparisonScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
isScrollLinked: boolean;
|
||||
setIsScrollLinked: (value: boolean) => void;
|
||||
captureScrollLinkDelta: () => void;
|
||||
clearScrollLinkDelta: () => void;
|
||||
isPanMode: boolean;
|
||||
setIsPanMode: (value: boolean) => void;
|
||||
baseZoom: number;
|
||||
setBaseZoom: (value: number) => void;
|
||||
comparisonZoom: number;
|
||||
setComparisonZoom: (value: number) => void;
|
||||
basePan: PanState;
|
||||
comparisonPan: PanState;
|
||||
setPanToTopLeft: (pane: ComparePane) => void;
|
||||
centerPanForZoom: (pane: ComparePane, zoom: number) => void;
|
||||
clampPanForZoom: (pane: ComparePane, zoom: number) => void;
|
||||
handleScrollSync: (source: HTMLDivElement | null, target: HTMLDivElement | null) => void;
|
||||
beginPan: (pane: ComparePane, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
continuePan: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
endPan: () => void;
|
||||
handleWheelZoom: (pane: ComparePane, event: React.WheelEvent<HTMLDivElement>) => void;
|
||||
handleWheelOverscroll: (pane: ComparePane, event: React.WheelEvent<HTMLDivElement>) => void;
|
||||
onTouchStart: (pane: ComparePane, event: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onTouchMove: (event: React.TouchEvent<HTMLDivElement>) => void;
|
||||
onTouchEnd: () => void;
|
||||
zoomLimits: { min: number; max: number; step: number };
|
||||
}
|
||||
|
||||
export interface PagePreview {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface WordHighlightEntry {
|
||||
rect: TokenBoundingBox;
|
||||
metaIndex: number;
|
||||
}
|
||||
|
||||
// Removed legacy upload section types; upload flow now uses the standard active files workbench
|
||||
|
||||
export interface CompareWorkbenchData {
|
||||
result: CompareResultData | null;
|
||||
baseFileId: FileId | null;
|
||||
comparisonFileId: FileId | null;
|
||||
onSelectBase?: (fileId: FileId | null) => void;
|
||||
onSelectComparison?: (fileId: FileId | null) => void;
|
||||
isLoading?: boolean;
|
||||
baseLocalFile?: StirlingFile | null;
|
||||
comparisonLocalFile?: StirlingFile | null;
|
||||
}
|
||||
|
||||
export interface CompareChangeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
pageNumber: number;
|
||||
}
|
||||
@ -86,7 +86,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations)
|
||||
// Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations)
|
||||
if (!exportActions) {
|
||||
console.error('No export actions available');
|
||||
return null;
|
||||
|
||||
50
frontend/src/core/utils/textDiff.ts
Normal file
50
frontend/src/core/utils/textDiff.ts
Normal file
@ -0,0 +1,50 @@
|
||||
// Shared text diff and normalization utilities for compare tool
|
||||
|
||||
export const shouldConcatWithoutSpace = (word: string) => {
|
||||
return /^[.,!?;:)\]}]/.test(word) || word.startsWith("'") || word === "'s";
|
||||
};
|
||||
|
||||
export const appendWord = (existing: string, word: string) => {
|
||||
if (!existing) return word;
|
||||
if (shouldConcatWithoutSpace(word)) return `${existing}${word}`;
|
||||
return `${existing} ${word}`;
|
||||
};
|
||||
export const tokenize = (text: string): string[] => text.split(/\s+/).filter(Boolean);
|
||||
|
||||
type TokenType = 'unchanged' | 'removed' | 'added';
|
||||
export interface LocalToken { type: TokenType; text: string }
|
||||
|
||||
const buildLcsMatrix = (a: string[], b: string[]) => {
|
||||
const rows = a.length + 1;
|
||||
const cols = b.length + 1;
|
||||
const m: number[][] = new Array(rows);
|
||||
for (let i = 0; i < rows; i += 1) m[i] = new Array(cols).fill(0);
|
||||
for (let i = 1; i < rows; i += 1) {
|
||||
for (let j = 1; j < cols; j += 1) {
|
||||
m[i][j] = a[i - 1] === b[j - 1] ? m[i - 1][j - 1] + 1 : Math.max(m[i][j - 1], m[i - 1][j]);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
export const diffWords = (a: string[], b: string[]): LocalToken[] => {
|
||||
const matrix = buildLcsMatrix(a, b);
|
||||
const tokens: LocalToken[] = [];
|
||||
let i = a.length;
|
||||
let j = b.length;
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
|
||||
tokens.unshift({ type: 'unchanged', text: a[i - 1] });
|
||||
i -= 1; j -= 1;
|
||||
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
|
||||
tokens.unshift({ type: 'added', text: b[j - 1] });
|
||||
j -= 1;
|
||||
} else if (i > 0) {
|
||||
tokens.unshift({ type: 'removed', text: a[i - 1] });
|
||||
i -= 1;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
||||
|
||||
452
frontend/src/core/workers/compareWorker.ts
Normal file
452
frontend/src/core/workers/compareWorker.ts
Normal file
@ -0,0 +1,452 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type {
|
||||
CompareDiffToken,
|
||||
CompareWorkerRequest,
|
||||
CompareWorkerResponse,
|
||||
} from '@app/types/compare';
|
||||
|
||||
declare const self: DedicatedWorkerGlobalScope;
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
batchSize: 5000,
|
||||
complexThreshold: 25000,
|
||||
maxWordThreshold: 60000,
|
||||
// Early stop configuration
|
||||
earlyStopEnabled: true,
|
||||
// Jaccard thresholds for quick prefilter (unigram/bigram)
|
||||
minJaccardUnigram: 0.005,
|
||||
minJaccardBigram: 0.003,
|
||||
// Only consider early stop when docs are reasonably large
|
||||
minTokensForEarlyStop: 20000,
|
||||
// Sampling cap for similarity estimation
|
||||
sampleLimit: 50000,
|
||||
// Runtime stop-loss during chunked diff
|
||||
runtimeMaxProcessedTokens: 150000,
|
||||
runtimeMinUnchangedRatio: 0.001,
|
||||
};
|
||||
|
||||
const buildMatrix = (words1: string[], words2: string[]) => {
|
||||
const rows = words1.length + 1;
|
||||
const cols = words2.length + 1;
|
||||
const matrix: number[][] = new Array(rows);
|
||||
|
||||
for (let i = 0; i < rows; i += 1) {
|
||||
matrix[i] = new Array(cols).fill(0);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= words1.length; i += 1) {
|
||||
for (let j = 1; j <= words2.length; j += 1) {
|
||||
matrix[i][j] =
|
||||
words1[i - 1] === words2[j - 1]
|
||||
? matrix[i - 1][j - 1] + 1
|
||||
: Math.max(matrix[i][j - 1], matrix[i - 1][j]);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
};
|
||||
|
||||
const backtrack = (matrix: number[][], words1: string[], words2: string[]): CompareDiffToken[] => {
|
||||
const tokens: CompareDiffToken[] = [];
|
||||
let i = words1.length;
|
||||
let j = words2.length;
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && words1[i - 1] === words2[j - 1]) {
|
||||
tokens.unshift({ type: 'unchanged', text: words1[i - 1] });
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if (j > 0 && (i === 0 || matrix[i][j] === matrix[i][j - 1])) {
|
||||
tokens.unshift({ type: 'added', text: words2[j - 1] });
|
||||
j -= 1;
|
||||
} else if (i > 0) {
|
||||
tokens.unshift({ type: 'removed', text: words1[i - 1] });
|
||||
i -= 1;
|
||||
} else {
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
};
|
||||
|
||||
const diff = (words1: string[], words2: string[]): CompareDiffToken[] => {
|
||||
if (words1.length === 0 && words2.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matrix = buildMatrix(words1, words2);
|
||||
return backtrack(matrix, words1, words2);
|
||||
};
|
||||
|
||||
const countBaseTokens = (segment: CompareDiffToken[]) =>
|
||||
segment.reduce((acc, token) => acc + (token.type !== 'added' ? 1 : 0), 0);
|
||||
|
||||
const countComparisonTokens = (segment: CompareDiffToken[]) =>
|
||||
segment.reduce((acc, token) => acc + (token.type !== 'removed' ? 1 : 0), 0);
|
||||
|
||||
const findLastUnchangedIndex = (segment: CompareDiffToken[]) => {
|
||||
for (let i = segment.length - 1; i >= 0; i -= 1) {
|
||||
if (segment[i].type === 'unchanged') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const chunkedDiff = (
|
||||
words1: string[],
|
||||
words2: string[],
|
||||
chunkSize: number,
|
||||
emit: (tokens: CompareDiffToken[]) => void,
|
||||
runtimeStop?: { maxProcessedTokens: number; minUnchangedRatio: number }
|
||||
) => {
|
||||
if (words1.length === 0 && words2.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseChunkSize = Math.max(1, chunkSize);
|
||||
let dynamicChunkSize = baseChunkSize;
|
||||
const baseMaxWindow = Math.max(baseChunkSize * 6, baseChunkSize + 512);
|
||||
let dynamicMaxWindow = baseMaxWindow;
|
||||
let dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1));
|
||||
let dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5));
|
||||
let stallIterations = 0;
|
||||
|
||||
const increaseChunkSizes = () => {
|
||||
const maxChunkSize = baseChunkSize * 8;
|
||||
if (dynamicChunkSize >= maxChunkSize) {
|
||||
return;
|
||||
}
|
||||
const nextChunk = Math.min(
|
||||
maxChunkSize,
|
||||
Math.max(dynamicChunkSize + dynamicStep, Math.floor(dynamicChunkSize * 1.5))
|
||||
);
|
||||
if (nextChunk === dynamicChunkSize) {
|
||||
return;
|
||||
}
|
||||
dynamicChunkSize = nextChunk;
|
||||
dynamicMaxWindow = Math.max(dynamicMaxWindow, Math.max(dynamicChunkSize * 6, dynamicChunkSize + 512));
|
||||
dynamicMinCommit = Math.max(1, Math.floor(dynamicChunkSize * 0.1));
|
||||
dynamicStep = Math.max(64, Math.floor(dynamicChunkSize * 0.5));
|
||||
};
|
||||
|
||||
let index1 = 0;
|
||||
let index2 = 0;
|
||||
let buffer1: string[] = [];
|
||||
let buffer2: string[] = [];
|
||||
let totalProcessedBase = 0;
|
||||
let totalProcessedComp = 0;
|
||||
let totalUnchanged = 0;
|
||||
|
||||
const countUnchanged = (segment: CompareDiffToken[]) =>
|
||||
segment.reduce((acc, token) => acc + (token.type === 'unchanged' ? 1 : 0), 0);
|
||||
|
||||
const flushRemainder = () => {
|
||||
if (buffer1.length === 0 && buffer2.length === 0) {
|
||||
return;
|
||||
}
|
||||
const finalTokens = diff(buffer1, buffer2);
|
||||
if (finalTokens.length > 0) {
|
||||
emit(finalTokens);
|
||||
}
|
||||
buffer1 = [];
|
||||
buffer2 = [];
|
||||
index1 = words1.length;
|
||||
index2 = words2.length;
|
||||
};
|
||||
|
||||
while (
|
||||
index1 < words1.length ||
|
||||
index2 < words2.length ||
|
||||
buffer1.length > 0 ||
|
||||
buffer2.length > 0
|
||||
) {
|
||||
const remaining1 = Math.max(0, words1.length - index1);
|
||||
const remaining2 = Math.max(0, words2.length - index2);
|
||||
|
||||
let windowSize = Math.max(dynamicChunkSize, buffer1.length, buffer2.length);
|
||||
let window1: string[] = [];
|
||||
let window2: string[] = [];
|
||||
let chunkTokens: CompareDiffToken[] = [];
|
||||
let reachedEnd = false;
|
||||
|
||||
while (true) {
|
||||
const take1 = Math.min(Math.max(0, windowSize - buffer1.length), remaining1);
|
||||
const take2 = Math.min(Math.max(0, windowSize - buffer2.length), remaining2);
|
||||
|
||||
const slice1 = take1 > 0 ? words1.slice(index1, index1 + take1) : [];
|
||||
const slice2 = take2 > 0 ? words2.slice(index2, index2 + take2) : [];
|
||||
|
||||
window1 = buffer1.length > 0 ? [...buffer1, ...slice1] : slice1;
|
||||
window2 = buffer2.length > 0 ? [...buffer2, ...slice2] : slice2;
|
||||
|
||||
if (window1.length === 0 && window2.length === 0) {
|
||||
flushRemainder();
|
||||
return;
|
||||
}
|
||||
|
||||
chunkTokens = diff(window1, window2);
|
||||
const lastStableIndex = findLastUnchangedIndex(chunkTokens);
|
||||
|
||||
reachedEnd =
|
||||
index1 + take1 >= words1.length &&
|
||||
index2 + take2 >= words2.length;
|
||||
|
||||
const windowTooLarge =
|
||||
window1.length >= dynamicMaxWindow ||
|
||||
window2.length >= dynamicMaxWindow;
|
||||
|
||||
if (lastStableIndex >= 0 || reachedEnd || windowTooLarge) {
|
||||
break;
|
||||
}
|
||||
|
||||
const canGrow1 = take1 < remaining1;
|
||||
const canGrow2 = take2 < remaining2;
|
||||
|
||||
if (!canGrow1 && !canGrow2) {
|
||||
break;
|
||||
}
|
||||
|
||||
windowSize = Math.min(
|
||||
dynamicMaxWindow,
|
||||
windowSize + dynamicStep
|
||||
);
|
||||
}
|
||||
|
||||
if (chunkTokens.length === 0) {
|
||||
if (reachedEnd) {
|
||||
flushRemainder();
|
||||
return;
|
||||
}
|
||||
windowSize = Math.min(windowSize + dynamicStep, dynamicMaxWindow);
|
||||
stallIterations += 1;
|
||||
if (stallIterations >= 3) {
|
||||
increaseChunkSizes();
|
||||
stallIterations = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let commitIndex = reachedEnd ? chunkTokens.length - 1 : findLastUnchangedIndex(chunkTokens);
|
||||
if (commitIndex < 0) {
|
||||
commitIndex = reachedEnd
|
||||
? chunkTokens.length - 1
|
||||
: Math.min(chunkTokens.length - 1, dynamicMinCommit - 1);
|
||||
}
|
||||
|
||||
const commitTokens = commitIndex >= 0 ? chunkTokens.slice(0, commitIndex + 1) : [];
|
||||
const baseConsumed = countBaseTokens(commitTokens);
|
||||
const comparisonConsumed = countComparisonTokens(commitTokens);
|
||||
|
||||
if (commitTokens.length > 0) {
|
||||
emit(commitTokens);
|
||||
}
|
||||
|
||||
const consumedFromNew1 = Math.max(0, baseConsumed - buffer1.length);
|
||||
const consumedFromNew2 = Math.max(0, comparisonConsumed - buffer2.length);
|
||||
|
||||
index1 += consumedFromNew1;
|
||||
index2 += consumedFromNew2;
|
||||
|
||||
buffer1 = window1.slice(baseConsumed);
|
||||
buffer2 = window2.slice(comparisonConsumed);
|
||||
// Update runtime counters and early stop if necessary
|
||||
totalProcessedBase += baseConsumed;
|
||||
totalProcessedComp += comparisonConsumed;
|
||||
totalUnchanged += countUnchanged(commitTokens);
|
||||
|
||||
if (runtimeStop) {
|
||||
const processedTotal = totalProcessedBase + totalProcessedComp;
|
||||
if (processedTotal >= runtimeStop.maxProcessedTokens) {
|
||||
const unchangedRatio = totalUnchanged / Math.max(1, processedTotal);
|
||||
if (unchangedRatio < runtimeStop.minUnchangedRatio) {
|
||||
// Signal early termination for extreme dissimilarity
|
||||
const err = new Error('EARLY_STOP_TOO_DISSIMILAR');
|
||||
(err as Error & { __earlyStop?: boolean }).__earlyStop = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reachedEnd) {
|
||||
flushRemainder();
|
||||
break;
|
||||
}
|
||||
|
||||
if (commitTokens.length < dynamicMinCommit) {
|
||||
stallIterations += 1;
|
||||
} else {
|
||||
stallIterations = 0;
|
||||
}
|
||||
|
||||
if (commitTokens.length === 0 && buffer1.length + buffer2.length > 0) {
|
||||
if (buffer1.length > 0 && index1 < words1.length) {
|
||||
buffer1 = buffer1.slice(1);
|
||||
index1 += 1;
|
||||
} else if (buffer2.length > 0 && index2 < words2.length) {
|
||||
buffer2 = buffer2.slice(1);
|
||||
index2 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (stallIterations >= 3) {
|
||||
increaseChunkSizes();
|
||||
stallIterations = 0;
|
||||
}
|
||||
}
|
||||
|
||||
flushRemainder();
|
||||
};
|
||||
|
||||
// Fast similarity estimation using sampled unigrams and bigrams with Jaccard
|
||||
const buildSampledSet = (tokens: string[], sampleLimit: number, ngram: 1 | 2): Set<string> => {
|
||||
const result = new Set<string>();
|
||||
if (tokens.length === 0) return result;
|
||||
const stride = Math.max(1, Math.ceil(tokens.length / sampleLimit));
|
||||
if (ngram === 1) {
|
||||
for (let i = 0; i < tokens.length; i += stride) {
|
||||
const t = tokens[i];
|
||||
if (t) result.add(t);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// ngram === 2
|
||||
for (let i = 0; i + 1 < tokens.length; i += stride) {
|
||||
const a = tokens[i];
|
||||
const b = tokens[i + 1];
|
||||
if (a && b) result.add(`${a}|${b}`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const jaccard = (a: Set<string>, b: Set<string>): number => {
|
||||
if (a.size === 0 && b.size === 0) return 1;
|
||||
if (a.size === 0 || b.size === 0) return 0;
|
||||
let intersection = 0;
|
||||
const smaller = a.size <= b.size ? a : b;
|
||||
const larger = a.size <= b.size ? b : a;
|
||||
for (const v of smaller) {
|
||||
if (larger.has(v)) intersection += 1;
|
||||
}
|
||||
const union = a.size + b.size - intersection;
|
||||
return union > 0 ? intersection / union : 0;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<CompareWorkerRequest>) => {
|
||||
const { data } = event;
|
||||
if (!data || data.type !== 'compare') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { baseTokens, comparisonTokens, warnings, settings } = data.payload;
|
||||
const {
|
||||
batchSize = DEFAULT_SETTINGS.batchSize,
|
||||
complexThreshold = DEFAULT_SETTINGS.complexThreshold,
|
||||
maxWordThreshold = DEFAULT_SETTINGS.maxWordThreshold,
|
||||
earlyStopEnabled = DEFAULT_SETTINGS.earlyStopEnabled,
|
||||
minJaccardUnigram = DEFAULT_SETTINGS.minJaccardUnigram,
|
||||
minJaccardBigram = DEFAULT_SETTINGS.minJaccardBigram,
|
||||
minTokensForEarlyStop = DEFAULT_SETTINGS.minTokensForEarlyStop,
|
||||
sampleLimit = DEFAULT_SETTINGS.sampleLimit,
|
||||
runtimeMaxProcessedTokens = DEFAULT_SETTINGS.runtimeMaxProcessedTokens,
|
||||
runtimeMinUnchangedRatio = DEFAULT_SETTINGS.runtimeMinUnchangedRatio,
|
||||
} = settings ?? {};
|
||||
|
||||
if (!baseTokens || !comparisonTokens || baseTokens.length === 0 || comparisonTokens.length === 0) {
|
||||
const response: CompareWorkerResponse = {
|
||||
type: 'error',
|
||||
message: warnings.emptyTextMessage ?? 'One or both texts are empty.',
|
||||
code: 'EMPTY_TEXT',
|
||||
};
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (baseTokens.length > maxWordThreshold || comparisonTokens.length > maxWordThreshold) {
|
||||
// For compare tool, do not fail hard; warn and continue with chunked diff
|
||||
const response: CompareWorkerResponse = {
|
||||
type: 'warning',
|
||||
message: warnings.tooLargeMessage ?? 'Documents are too large to compare.',
|
||||
};
|
||||
self.postMessage(response);
|
||||
}
|
||||
|
||||
const isComplex = baseTokens.length > complexThreshold || comparisonTokens.length > complexThreshold;
|
||||
|
||||
if (isComplex && warnings.complexMessage) {
|
||||
const warningResponse: CompareWorkerResponse = {
|
||||
type: 'warning',
|
||||
message: warnings.complexMessage,
|
||||
};
|
||||
self.postMessage(warningResponse);
|
||||
}
|
||||
|
||||
// Quick prefilter to avoid heavy diff on extremely dissimilar large docs
|
||||
if (earlyStopEnabled && Math.min(baseTokens.length, comparisonTokens.length) >= minTokensForEarlyStop) {
|
||||
const set1u = buildSampledSet(baseTokens, sampleLimit, 1);
|
||||
const set2u = buildSampledSet(comparisonTokens, sampleLimit, 1);
|
||||
const jUni = jaccard(set1u, set2u);
|
||||
const set1b = buildSampledSet(baseTokens, sampleLimit, 2);
|
||||
const set2b = buildSampledSet(comparisonTokens, sampleLimit, 2);
|
||||
const jBi = jaccard(set1b, set2b);
|
||||
if (jUni < minJaccardUnigram && jBi < minJaccardBigram) {
|
||||
const response: CompareWorkerResponse = {
|
||||
type: 'error',
|
||||
message:
|
||||
warnings.tooDissimilarMessage ??
|
||||
'These documents appear highly dissimilar. Comparison was stopped to save time.',
|
||||
code: 'TOO_DISSIMILAR',
|
||||
};
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
chunkedDiff(
|
||||
baseTokens,
|
||||
comparisonTokens,
|
||||
batchSize,
|
||||
(tokens) => {
|
||||
if (tokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
const response: CompareWorkerResponse = {
|
||||
type: 'chunk',
|
||||
tokens,
|
||||
};
|
||||
self.postMessage(response);
|
||||
},
|
||||
{ maxProcessedTokens: runtimeMaxProcessedTokens, minUnchangedRatio: runtimeMinUnchangedRatio }
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as Error & { __earlyStop?: boolean };
|
||||
if (error && (error.__earlyStop || error.message === 'EARLY_STOP_TOO_DISSIMILAR')) {
|
||||
const response: CompareWorkerResponse = {
|
||||
type: 'error',
|
||||
message:
|
||||
warnings.tooDissimilarMessage ??
|
||||
'These documents appear highly dissimilar. Comparison was stopped to save time.',
|
||||
code: 'TOO_DISSIMILAR',
|
||||
};
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const durationMs = performance.now() - start;
|
||||
|
||||
const response: CompareWorkerResponse = {
|
||||
type: 'success',
|
||||
stats: {
|
||||
baseWordCount: baseTokens.length,
|
||||
comparisonWordCount: comparisonTokens.length,
|
||||
durationMs,
|
||||
},
|
||||
};
|
||||
|
||||
self.postMessage(response);
|
||||
};
|
||||
@ -1,11 +1,11 @@
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
|
||||
export function useShouldShowWelcomeModal(): boolean {
|
||||
const { preferences } = usePreferences();
|
||||
const { session, loading } = useAuth();
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Only show welcome modal if user is authenticated (session exists)
|
||||
// This prevents the modal from showing on login screens when security is enabled
|
||||
|
||||
Loading…
Reference in New Issue
Block a user