diff --git a/frontend/public/images/google-drive.svg b/frontend/public/images/google-drive.svg new file mode 100644 index 0000000000..03b2f21290 --- /dev/null +++ b/frontend/public/images/google-drive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index ec34d0f0c6..c24f632b38 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useRef, useMemo, useEffect } from "react"; +import { flushSync } from "react-dom"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { Dropzone } from "@mantine/dropzone"; import { @@ -306,8 +307,23 @@ const FileEditor = ({ // Insert files at the calculated position newOrder.splice(insertIndex, 0, ...filesToMove); - // Update file order - reorderFiles(newOrder); + // Animate the reorder using the View Transitions API where available. + // Each FileEditorThumbnail carries a stable `view-transition-name`, so + // the browser snapshots each card before and after the DOM reorder and + // interpolates the positions automatically. `flushSync` forces React to + // apply the reorderFiles dispatch synchronously inside the transition + // callback so the BEFORE/AFTER snapshots capture the correct frames. + const applyReorder = () => reorderFiles(newOrder); + const docWithViewTransition = document as Document & { + startViewTransition?: (cb: () => void) => unknown; + }; + if (typeof docWithViewTransition.startViewTransition === "function") { + docWithViewTransition.startViewTransition(() => { + flushSync(applyReorder); + }); + } else { + applyReorder(); + } // Update status const moveCount = filesToMove.length; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index dab8fd7b5b..81ea1d27d3 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -99,6 +99,7 @@ const FileEditorThumbnail = ({ // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); const dragElementRef = useRef(null); const [showHoverMenu, setShowHoverMenu] = useState(false); const isMobile = useIsMobile(); @@ -210,7 +211,10 @@ const FileEditorThumbnail = ({ const sourceData = source.data; return sourceData.type === "file" && sourceData.fileId !== file.id; }, + onDragEnter: () => setIsDragOver(true), + onDragLeave: () => setIsDragOver(false), onDrop: ({ source }) => { + setIsDragOver(false); const sourceData = source.data; if (sourceData.type === "file" && onReorderFiles) { const sourceFileId = sourceData.fileId as FileId; @@ -435,7 +439,22 @@ const FileEditorThumbnail = ({ data-selected={isSelected} data-supported={isSupported} className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`} - style={{ opacity: isDragging ? 0.9 : 1 }} + style={ + { + opacity: isDragging ? 0.4 : 1, + outline: isDragOver + ? "3px dashed var(--mantine-color-blue-5, #3b82f6)" + : undefined, + outlineOffset: isDragOver ? "2px" : undefined, + transform: isDragOver ? "scale(1.02)" : undefined, + transition: + "outline 120ms ease, transform 120ms ease, opacity 120ms ease", + // Tag each card with a stable, unique view-transition-name so the + // browser can animate the reorder (see FileEditor.handleReorderFiles, + // which dispatches reorderFiles inside document.startViewTransition). + viewTransitionName: `file-card-${file.id}`, + } as React.CSSProperties + } tabIndex={0} role="listitem" aria-selected={isSelected} diff --git a/frontend/src/core/components/fileManager/FileInfoCard.tsx b/frontend/src/core/components/fileManager/FileInfoCard.tsx index 411e05e966..594442aefb 100644 --- a/frontend/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/core/components/fileManager/FileInfoCard.tsx @@ -277,7 +277,12 @@ const FileInfoCard: React.FC = ({ {t("fileManager.storageState", "Storage")} - + {t("fileManager.localOnly", "Local only")} diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index d8c7cb5dd2..88b2b4e58b 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -270,7 +270,12 @@ const FileListItem: React.FC = ({ : t("storageShare.roleViewer", "Viewer")} ) : isLocalOnly ? ( - + {t("fileManager.localOnly", "Local only")} ) : uploadEnabled && isOutOfSync ? ( diff --git a/frontend/src/core/components/shared/LandingPage.css b/frontend/src/core/components/shared/LandingPage.css index 9b09a0d068..e818217600 100644 --- a/frontend/src/core/components/shared/LandingPage.css +++ b/frontend/src/core/components/shared/LandingPage.css @@ -138,3 +138,15 @@ .landing-btn-icon { color: var(--accent-interactive) !important; } + +/* Dropzone accept/reject outlines. Mantine 8 no longer supports nested + * `&[data-accept]` selectors inside the `styles` prop object, so these are + * plain CSS attribute selectors on a class applied to the Dropzone root. */ +.landing-dropzone[data-accept] { + outline: 2px dashed var(--accent-interactive); + outline-offset: 4px; +} +.landing-dropzone[data-reject] { + outline: 2px dashed var(--mantine-color-red-6); + outline-offset: 4px; +} diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx index 2690463120..e52eb98b56 100644 --- a/frontend/src/core/components/shared/LandingPage.tsx +++ b/frontend/src/core/components/shared/LandingPage.tsx @@ -61,20 +61,12 @@ const LandingPage = () => { activateOnClick={false} enablePointerEvents aria-label={terminology.dropFilesHere} - className="flex min-h-0 flex-1 cursor-default flex-col items-center justify-center border-none bg-transparent px-4 py-8 shadow-none outline-none" + className="landing-dropzone flex min-h-0 flex-1 cursor-default flex-col items-center justify-center border-none bg-transparent px-4 py-8 shadow-none outline-none" styles={{ root: { border: "none !important", backgroundColor: "transparent", overflow: "visible", - "&[data-accept]": { - outline: "2px dashed var(--accent-interactive)", - outlineOffset: 4, - }, - "&[data-reject]": { - outline: "2px dashed var(--mantine-color-red-6)", - outlineOffset: 4, - }, }, inner: { overflow: "visible", diff --git a/frontend/src/core/contexts/FilesModalContext.tsx b/frontend/src/core/contexts/FilesModalContext.tsx index 3665bbce0f..aec1915954 100644 --- a/frontend/src/core/contexts/FilesModalContext.tsx +++ b/frontend/src/core/contexts/FilesModalContext.tsx @@ -382,9 +382,15 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ if (actions.addStirlingFileStubs) { await actions.addStirlingFileStubs(localStubs, { selectFiles: false }); + // Union newly picked files with the current selection so tools like + // Compare that depend on multi-file selection don't lose existing + // selections when the user picks an additional file from the modal. const requestedIds = localStubs.map((s) => s.id); + const currentSelected = fileCtx.selectors + .getSelectedStirlingFileStubs() + .map((s) => s.id); const nextSelection = Array.from( - new Set([...requestedIds, ...selectedFromServer]), + new Set([...currentSelected, ...requestedIds, ...selectedFromServer]), ); actions.setSelectedFiles(nextSelection); } else { diff --git a/frontend/src/core/hooks/tools/shared/useBaseTool.ts b/frontend/src/core/hooks/tools/shared/useBaseTool.ts index cd1194c7e9..df21dc3b3b 100644 --- a/frontend/src/core/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/core/hooks/tools/shared/useBaseTool.ts @@ -48,10 +48,19 @@ export function useBaseTool< minFiles?: number; /** When true, uses the full file selection rather than the viewer-scoped single file. */ ignoreViewerScope?: boolean; + /** + * When true, skips the "reset params on 0→N files" effect. Tools that + * manage their own parameter lifecycle (e.g. Compare, which auto-fills slots + * from the loaded files) opt out so useBaseTool's reset doesn't race and + * clobber the tool's own setParameters. + */ + skipResetParamsOnFirstFiles?: boolean; }, ): BaseToolReturn { const minFiles = options?.minFiles ?? 1; const ignoreViewerScope = options?.ignoreViewerScope ?? false; + const skipResetParamsOnFirstFiles = + options?.skipResetParamsOnFirstFiles ?? false; const { onPreviewFile, onComplete, onError } = props; const viewerScopedFiles = useViewScopedFiles(ignoreViewerScope); @@ -123,12 +132,16 @@ export function useBaseTool< const currentFileCount = effectiveFiles.length; const prevFileCount = previousFileCount.current; - if (prevFileCount === 0 && currentFileCount > 0) { + if ( + prevFileCount === 0 && + currentFileCount > 0 && + !skipResetParamsOnFirstFiles + ) { params.resetParameters(); } previousFileCount.current = currentFileCount; - }, [effectiveFiles.length]); + }, [effectiveFiles.length, skipResetParamsOnFirstFiles]); // Standard handlers const handleExecute = useCallback(async () => { diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index 6c7edfae26..46809c07a3 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -1091,3 +1091,12 @@ fill: #c56565; fill-opacity: 1; } + +/* Snappier View-Transitions for FileEditor card reordering. Each thumbnail + * is tagged with `view-transition-name: file-card-` (see + * FileEditorThumbnail.tsx); the browser FLIPs it between old and new + * positions on reorder. Override the sluggish 250ms default. */ +::view-transition-group(*) { + animation-duration: 160ms; + animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1); +} diff --git a/frontend/src/core/tests/compare/CompareE2E.spec.ts b/frontend/src/core/tests/compare/CompareE2E.spec.ts new file mode 100644 index 0000000000..5fc568bacd --- /dev/null +++ b/frontend/src/core/tests/compare/CompareE2E.spec.ts @@ -0,0 +1,305 @@ +/** + * End-to-End Tests for Compare Tool + * + * Regression coverage for the Compare slot auto-fill flow. + * + * Background: when a user has one file in the workbench and picks a second + * file from the "My Files" picker (triggering handleRecentFileSelect), the + * existing selection used to be replaced rather than unioned, so the Original + * slot jumped to the new file and the Edited slot stayed empty. See fix in + * FilesModalContext.handleRecentFileSelect that unions the newly picked IDs + * with the current selection. + * + * The end-to-end invariant this test guards: after two distinct PDFs have been + * added to the workbench through the file modal's add buttons, both Compare + * slots are populated and the Compare action button is enabled. + * + * All backend API calls are mocked via page.route() — no real backend required. + * The Vite dev server must be running (handled by playwright.config.ts webServer). + */ + +import { test, expect, type Page } from "@playwright/test"; +import path from "path"; + +const FIXTURES_DIR = path.join(__dirname, "../test-fixtures"); +const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf"); +const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf"); + +async function mockAppApis(page: Page) { + await page.route("**/api/v1/info/status", (route) => + route.fulfill({ json: { status: "UP" } }), + ); + await page.route("**/api/v1/config/app-config", (route) => + route.fulfill({ + json: { + enableLogin: false, + languages: ["en-GB"], + defaultLocale: "en-GB", + }, + }), + ); + await page.route("**/api/v1/auth/me", (route) => + route.fulfill({ + json: { + id: 1, + username: "testuser", + email: "test@example.com", + roles: ["ROLE_USER"], + }, + }), + ); + await page.route("**/api/v1/config/endpoints-availability", (route) => + route.fulfill({ json: { compare: { enabled: true } } }), + ); + await page.route("**/api/v1/config/endpoint-enabled*", (route) => + route.fulfill({ json: true }), + ); + await page.route("**/api/v1/config/group-enabled*", (route) => + route.fulfill({ json: true }), + ); + await page.route("**/api/v1/ui-data/footer-info", (route) => + route.fulfill({ json: {} }), + ); + await page.route("**/api/v1/proprietary/**", (route) => + route.fulfill({ json: {} }), + ); +} + +async function navigateToCompare(page: Page) { + await page.locator('[data-tour="tool-button-compare"]').first().click(); + await page.waitForSelector('[data-testid="compare-slot-base"]', { + timeout: 5000, + }); +} + +async function uploadIntoSlot( + page: Page, + role: "base" | "comparison", + filePath: string, + expectedFilename: string, +) { + await page.locator(`[data-testid="compare-slot-${role}-add"]`).click(); + await page.waitForSelector(".mantine-Modal-overlay", { + state: "visible", + timeout: 5000, + }); + await page.locator('[data-testid="file-input"]').setInputFiles(filePath); + await page.waitForSelector(".mantine-Modal-overlay", { + state: "hidden", + timeout: 10000, + }); + + const slot = page.locator(`[data-testid="compare-slot-${role}"]`); + await expect(slot).toHaveAttribute("data-slot-state", "filled", { + timeout: 10000, + }); + await expect(slot).toHaveAttribute("data-slot-filename", expectedFilename); +} + +test.describe("Compare tool slot selection", () => { + test.beforeEach(async ({ page }) => { + await mockAppApis(page); + await page.goto("/?bypassOnboarding=true"); + await page.waitForSelector('[data-tour="tool-button-compare"]', { + timeout: 10000, + }); + }); + + test("Original slot fills when a PDF is dropped via the landing dropzone", async ({ + page, + }) => { + // Regression guard for the "middle state" bug: the landing dropzone path + // (FileManager's hidden file-input used by the drop-prompt on the empty + // workbench) dispatches ADD_FILES and SET_SELECTED_FILES on a different + // cadence than the modal-based upload. During the window where files.ids + // has the new file but selectedFileIds is still empty, the slot would + // show the placeholder unless the auto-fill falls back to allIds. + await navigateToCompare(page); + + const baseSlot = page.locator('[data-testid="compare-slot-base"]'); + await expect(baseSlot).toHaveAttribute("data-slot-state", "empty"); + + // Target the visible landing-prompt file input on the empty Compare tool. + const visibleFileInput = page.locator( + 'input[type="file"]:not([data-testid="file-input"])', + ); + await visibleFileInput.first().setInputFiles(PDF_A); + + // Assert the slot fills within a short window. Without the allIds + // fallback the slot stayed on the placeholder for 4+ seconds in manual + // testing because the SET_SELECTED_FILES dispatch lagged the ADD_FILES + // dispatch by many renders. + await expect(baseSlot).toHaveAttribute("data-slot-state", "filled", { + timeout: 2000, + }); + await expect(baseSlot).toHaveAttribute( + "data-slot-filename", + "compare_sample_a.pdf", + ); + }); + + test("uploading a PDF via the Original add button fills the Original slot", async ({ + page, + }) => { + await navigateToCompare(page); + + const baseSlot = page.locator('[data-testid="compare-slot-base"]'); + const comparisonSlot = page.locator( + '[data-testid="compare-slot-comparison"]', + ); + + await expect(baseSlot).toHaveAttribute("data-slot-state", "empty"); + await expect(comparisonSlot).toHaveAttribute("data-slot-state", "empty"); + + await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf"); + + await expect(comparisonSlot).toHaveAttribute("data-slot-state", "empty"); + }); + + test("uploading into both slots fills them and enables the Compare button", async ({ + page, + }) => { + await navigateToCompare(page); + + // Original slot first + await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf"); + + // Edited slot second. This upload goes through handleFileUpload, which + // adds via the internal selectFiles: true path (union with existing + // selection). If the union breaks — as it did in FilesModalContext before + // the fix to handleRecentFileSelect — the Original slot would be clobbered + // and the Edited slot would remain empty. + await uploadIntoSlot(page, "comparison", PDF_B, "compare_sample_b.pdf"); + + // Both slots must remain populated with their respective files. + const baseSlot = page.locator('[data-testid="compare-slot-base"]'); + const comparisonSlot = page.locator( + '[data-testid="compare-slot-comparison"]', + ); + await expect(baseSlot).toHaveAttribute("data-slot-state", "filled"); + await expect(baseSlot).toHaveAttribute( + "data-slot-filename", + "compare_sample_a.pdf", + ); + await expect(comparisonSlot).toHaveAttribute("data-slot-state", "filled"); + await expect(comparisonSlot).toHaveAttribute( + "data-slot-filename", + "compare_sample_b.pdf", + ); + + // Compare button should be enabled now that both slots are set. + const compareButton = page.getByRole("button", { name: "Compare" }); + await expect(compareButton).toBeEnabled(); + }); + + test("picking a stored file from the recent-files modal unions it with the current selection", async ({ + page, + }) => { + // This test specifically guards the handleRecentFileSelect code path in + // FilesModalContext. Setup: + // 1. Upload PDF_A so it lands in IndexedDB storage. + // 2. Reload the page — workbench resets but IndexedDB persists. + // 3. Upload PDF_B fresh so only PDF_B is in the workbench and selected. + // 4. Open the Files modal and pick PDF_A from "Recent". + // Without the fix, step 4 REPLACES the selection with [PDF_A] and the + // Compare Original slot jumps to PDF_A while Edited stays empty. With the + // fix, the selection becomes [PDF_B, PDF_A] and both slots populate. + + // Step 1 — upload PDF_A so it's persisted in IndexedDB. + await navigateToCompare(page); + await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf"); + + // Step 2 — reload. Workbench clears but IndexedDB retains PDF_A. + // page.goto reuses the mocked routes and re-applies bypassOnboarding. + await page.goto("/?bypassOnboarding=true"); + await page.waitForSelector('[data-tour="tool-button-compare"]', { + timeout: 10000, + }); + await navigateToCompare(page); + await expect( + page.locator('[data-testid="compare-slot-base"]'), + ).toHaveAttribute("data-slot-state", "empty"); + + // Step 3 — upload PDF_B fresh. Now workbench = [PDF_B], IndexedDB = [A, B]. + await uploadIntoSlot(page, "base", PDF_B, "compare_sample_b.pdf"); + await expect( + page.locator('[data-testid="compare-slot-comparison"]'), + ).toHaveAttribute("data-slot-state", "empty"); + + // Step 4 — open the modal from the sidebar Files button and pick PDF_A + // from the recent list. + await page.getByTestId("files-button").click(); + await page.waitForSelector(".mantine-Modal-overlay", { + state: "visible", + timeout: 5000, + }); + + // The recent-files list renders an entry per stored file. Click the + // checkbox for PDF_A (NOT the ACTIVE entry for PDF_B). We target the + // label text to disambiguate. + const pdfARow = page + .locator(".mantine-Modal-root") + .locator("text=compare_sample_a.pdf") + .first(); + await pdfARow.click(); + + // Confirm selection via "Open File". + await page.getByRole("button", { name: "Open File" }).click(); + await page.waitForSelector(".mantine-Modal-overlay", { + state: "hidden", + timeout: 10000, + }); + + // Both slots must be populated. Specifically, the Original slot must still + // be PDF_B (the pre-existing selection, not clobbered) and the Edited slot + // must be PDF_A (newly unioned in). + const baseSlot = page.locator('[data-testid="compare-slot-base"]'); + const comparisonSlot = page.locator( + '[data-testid="compare-slot-comparison"]', + ); + await expect(baseSlot).toHaveAttribute("data-slot-state", "filled", { + timeout: 10000, + }); + await expect(comparisonSlot).toHaveAttribute("data-slot-state", "filled", { + timeout: 10000, + }); + + const compareButton = page.getByRole("button", { name: "Compare" }); + await expect(compareButton).toBeEnabled(); + }); + + test("Clear selected empties both slots, clears the selection banner, and removes files from the workbench", async ({ + page, + }) => { + await navigateToCompare(page); + await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf"); + await uploadIntoSlot(page, "comparison", PDF_B, "compare_sample_b.pdf"); + + // Sanity: both slots filled before the clear. + await expect( + page.locator('[data-testid="compare-slot-base"]'), + ).toHaveAttribute("data-slot-state", "filled"); + await expect( + page.locator('[data-testid="compare-slot-comparison"]'), + ).toHaveAttribute("data-slot-state", "filled"); + + // Open the Clear confirmation modal and confirm. + await page.getByRole("button", { name: "Clear selected" }).click(); + await page.getByRole("button", { name: "Clear and return" }).click(); + + // The tool re-mounts on workbench switch; wait for the empty-state base slot. + await page.waitForSelector( + '[data-testid="compare-slot-base"][data-slot-state="empty"]', + { timeout: 10000 }, + ); + await expect( + page.locator('[data-testid="compare-slot-comparison"]'), + ).toHaveAttribute("data-slot-state", "empty"); + + // The "N files selected" / "Selected: X" banner must be gone because the + // files have been removed from the workbench. + await expect( + page.getByText(/\d+\s+files?\s+selected|Selected:\s+/i), + ).toHaveCount(0); + }); +}); diff --git a/frontend/src/core/tests/test-fixtures/compare_sample_a.pdf b/frontend/src/core/tests/test-fixtures/compare_sample_a.pdf new file mode 100644 index 0000000000..f625c11469 Binary files /dev/null and b/frontend/src/core/tests/test-fixtures/compare_sample_a.pdf differ diff --git a/frontend/src/core/tests/test-fixtures/compare_sample_b.pdf b/frontend/src/core/tests/test-fixtures/compare_sample_b.pdf new file mode 100644 index 0000000000..67f895ebf1 --- /dev/null +++ b/frontend/src/core/tests/test-fixtures/compare_sample_b.pdf @@ -0,0 +1,74 @@ +%PDF-1.3 +%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com) + /Subject (unspecified) /Title (untitled) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 147 +>> +stream +GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000073 00000 n +0000000114 00000 n +0000000221 00000 n +0000000333 00000 n +0000000526 00000 n +0000000594 00000 n +0000000890 00000 n +0000000949 00000 n +trailer +<< +/ID +[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>] +% ReportLab generated PDF document -- digest (http://www.reportlab.com) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1186 +%%EOF diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index 0bc8052639..c88ee801a9 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -59,6 +59,9 @@ const Compare = (props: BaseToolProps) => { { minFiles: 2, ignoreViewerScope: true, + // Compare auto-fills slots from loaded files via its own effect; the + // shared reset races and clobbers those writes. + skipResetParamsOnFirstFiles: true, }, ); @@ -92,6 +95,17 @@ const Compare = (props: BaseToolProps) => { } catch { console.error("Failed to clear selections"); } + // Also remove the loaded files from the workbench so the selection + // indicator resets to zero. Files stay in IndexedDB so users can re-pick + // them from "Recent" later. + try { + const loadedIds = fileState.files.ids as FileId[]; + if (loadedIds.length > 0) { + void fileActions.removeFiles(loadedIds, false); + } + } catch { + console.error("Failed to remove workbench files"); + } clearCustomWorkbenchViewData(CUSTOM_VIEW_ID); navigationActions.setWorkbench(getDefaultWorkbench()); }, [ @@ -99,6 +113,7 @@ const Compare = (props: BaseToolProps) => { base.params, clearCustomWorkbenchViewData, fileActions, + fileState.files.ids, navigationActions, ]); @@ -168,9 +183,33 @@ const Compare = (props: BaseToolProps) => { return; } - const nextBase = (selectedIds[0] ?? null) as FileId | null; - const nextComp = (selectedIds[1] ?? null) as FileId | null; + // Sticky slot mapping: preserve the current slot if its file is still selected, + // otherwise fill empty slots from the selection in order. This lets users add/remove + // files from the selection without the other slot being clobbered. + // + // Fallback: when there's no selection yet but files are loaded in the workbench, + // draw from `allIds`. This covers the brief window between ADD_FILES and + // SET_SELECTED_FILES during an upload (the selection dispatch can lag the file + // dispatch by several renders), which would otherwise leave the slot showing + // the "Select the original PDF" placeholder until the selection catches up. + const sourceIds = selectedIds.length > 0 ? selectedIds : allIds; base.params.setParameters((prev) => { + const prevBase = prev.baseFileId as FileId | null; + const prevComp = prev.comparisonFileId as FileId | null; + + const keepBase = + prevBase && sourceIds.includes(prevBase) ? prevBase : null; + const nextBase: FileId | null = + keepBase ?? ((sourceIds[0] ?? null) as FileId | null); + + const keepComp = + prevComp && sourceIds.includes(prevComp) && prevComp !== nextBase + ? prevComp + : null; + const nextComp: FileId | null = + keepComp ?? + ((sourceIds.find((id) => id !== nextBase) ?? null) as FileId | null); + if (prev.baseFileId === nextBase && prev.comparisonFileId === nextComp) return prev; return { ...prev, baseFileId: nextBase, comparisonFileId: nextComp }; @@ -443,6 +482,8 @@ const Compare = (props: BaseToolProps) => { return ( { {shouldShowAddButton && ( { return (
Stirling PDF