Fix compare tool file selection and other files improvements (#6133)

This commit is contained in:
Anthony Stirling
2026-04-20 12:53:37 +01:00
committed by GitHub
parent 30aff3236f
commit b4b196556d
15 changed files with 522 additions and 20 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#1e88e5" d="M38.59,39c-0.535,0.93-0.298,1.68-1.195,2.197C36.498,41.715,35.465,42,34.39,42H13.61 c-1.074,0-2.106-0.285-3.004-0.802C9.708,40.681,9.945,39.93,9.41,39l7.67-9h13.84L38.59,39z"/><path fill="#fbc02d" d="M27.463,6.999c1.073-0.002,2.104-0.716,3.001-0.198c0.897,0.519,1.66,1.27,2.197,2.201l10.39,17.996 c0.537,0.93,0.807,1.967,0.808,3.002c0.001,1.037-1.267,2.073-1.806,3.001l-11.127-3.005l-6.924-11.993L27.463,6.999z"/><path fill="#e53935" d="M43.86,30c0,1.04-0.27,2.07-0.81,3l-3.67,6.35c-0.53,0.78-1.21,1.4-1.99,1.85L30.92,30H43.86z"/><path fill="#4caf50" d="M5.947,33.001c-0.538-0.928-1.806-1.964-1.806-3c0.001-1.036,0.27-2.073,0.808-3.004l10.39-17.996 c0.537-0.93,1.3-1.682,2.196-2.2c0.897-0.519,1.929,0.195,3.002,0.197l3.459,11.009l-6.922,11.989L5.947,33.001z"/><path fill="#1565c0" d="M17.08,30l-6.47,11.2c-0.78-0.45-1.46-1.07-1.99-1.85L4.95,33c-0.54-0.93-0.81-1.96-0.81-3H17.08z"/><path fill="#2e7d32" d="M30.46,6.8L24,18L17.53,6.8c0.78-0.45,1.66-0.73,2.6-0.79L27.46,6C28.54,6,29.57,6.28,30.46,6.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

@@ -99,6 +99,7 @@ const FileEditorThumbnail = ({
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(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}

View File

@@ -277,7 +277,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<Text size="sm" c="dimmed">
{t("fileManager.storageState", "Storage")}
</Text>
<Badge size="xs" variant="light" color="gray">
<Badge
size="xs"
variant="default"
c="dimmed"
style={{ opacity: 0.75 }}
>
{t("fileManager.localOnly", "Local only")}
</Badge>
</Group>

View File

@@ -270,7 +270,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
: t("storageShare.roleViewer", "Viewer")}
</Badge>
) : isLocalOnly ? (
<Badge size="xs" variant="light" color="gray">
<Badge
size="xs"
variant="default"
c="dimmed"
style={{ opacity: 0.75 }}
>
{t("fileManager.localOnly", "Local only")}
</Badge>
) : uploadEnabled && isOutOfSync ? (

View File

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

View File

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

View File

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

View File

@@ -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<TParams, TParamsHook> {
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 () => {

View File

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

View File

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

View File

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

View File

@@ -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 (
<Stack gap={6}>
<Box
data-testid={`compare-slot-${role}`}
data-slot-state="empty"
style={{
border: "1px solid var(--border-default)",
borderRadius: "var(--radius-md)",
@@ -470,6 +511,7 @@ const Compare = (props: BaseToolProps) => {
</Text>
{shouldShowAddButton && (
<ActionIcon
data-testid={`compare-slot-${role}-add`}
variant="filled"
color="blue"
size="sm"
@@ -500,6 +542,9 @@ const Compare = (props: BaseToolProps) => {
return (
<Stack gap={6}>
<Box
data-testid={`compare-slot-${role}`}
data-slot-state="filled"
data-slot-filename={stub?.name}
style={{
border: "1px solid var(--border-default)",
borderRadius: "var(--radius-md)",

View File

@@ -181,7 +181,7 @@ export default function AuthCallback() {
>
<div className="text-center">
<img
src={withBasePath("/branding/StirlingPDFLogoNoTextDark.svg")}
src={withBasePath("/modern-logo/StirlingPDFLogoNoTextDark.svg")}
alt="Stirling PDF"
className="mx-auto mb-5 h-8 opacity-80"
/>