From be8577385279c6391d44f10ee580a0680fa16bd3 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:03:59 +0100 Subject: [PATCH] remove selected and de-selected logic and change the compare tool to accomadate (#6095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Changes 1. Files are no longer selected/deselected, they're just on the workbench or not. 2. Compare tool logic was changed to accommodate the changes in file selection 3. Added a re-usable file selector component that allows you to select files from the workbench / saved files / upload files and used it in the compare tool. Screenshot 2026-04-10 at 5 28
07 PM 3. Files on active files are cleaner looking, on hover shows buttons, tool runs, versions Screenshot 2026-04-10 at 4 56 52 PM Screenshot 2026-04-10 at 4 57 05 PM # How to test ## Compare tool 1. Open the Compare tool with no files on the workbench 3. Click the Original PDF picker → confirm the popover opens with Saved Files and Workbench tabs 5. Switch to Saved Files, pick a file → confirm it appears as a card but does not appear in the workbench file list 6. Pick a second file for Edited PDF → confirm the Compare button becomes active 7. Hit Compare → confirm results appear in the workbench view 8. Hit Swap → confirm a re-run is triggered and results update correctly 9. Use the upload button → confirm a thumbnail appears on the card immediately 10. Clear selected → confirm both slots reset and the workbench view closes ## FileSelectorPicker 1. Open either slot picker and switch between Saved Files / Workbench tabs — confirm the active tab is remembered when you open the other slot's picker 3. Sort by A–Z then reopen — confirm sort preference persists 4. Upload a PDF via the Upload tab button — confirm thumbnail shows in the card, file appears in Saved Files on next open 5. With a file in the other slot, confirm it is hidden from both tab lists (excludeIds) ## File editor 1. Load 3+ files, run any tool (e.g. Compress) — confirm it runs on all files without needing to select any 2. Confirm Close All removes all files from the workbench --- frontend/.prettierignore | 2 + .../public/locales/en-GB/translation.toml | 96 ++-- frontend/src/core/components/FileManager.tsx | 3 +- .../core/components/fileEditor/FileEditor.tsx | 116 +---- .../fileEditor/FileEditorThumbnail.module.css | 246 ++++++++++ .../fileEditor/FileEditorThumbnail.tsx | 461 +++++++----------- .../fileEditor/fileEditorRightRailButtons.tsx | 47 +- .../fileManager/CompactFileDetails.tsx | 2 +- .../components/fileManager/FileListItem.tsx | 18 +- .../core/components/shared/BulkShareModal.tsx | 2 +- .../shared/BulkUploadToServerModal.tsx | 2 +- .../components/shared/FilePickerModal.tsx | 2 +- .../shared/FileSelectorPicker.module.css | 244 +++++++++ .../components/shared/FileSelectorPicker.tsx | 408 ++++++++++++++++ .../components/shared/HoverActionMenu.tsx | 24 +- .../components/shared/MultiSelectControls.tsx | 4 +- .../shared/signing/CreateSessionPanel.tsx | 4 +- .../tools/compare/CompareDocumentPane.tsx | 9 +- .../components/tools/compare/compareView.css | 92 ++++ .../components/tools/crop/CropSettings.tsx | 16 +- .../tools/rotate/RotateSettings.tsx | 10 +- .../tools/shared/FileStatusIndicator.tsx | 8 +- .../tools/shared/ScopedOperationButton.tsx | 11 +- .../core/components/viewer/NonPdfViewer.tsx | 2 +- .../src/core/contexts/FileManagerContext.tsx | 45 +- .../src/core/contexts/FilesModalContext.tsx | 12 +- .../changeMetadata/useMetadataExtraction.ts | 4 +- .../getPdfInfo/useGetPdfInfoOperation.ts | 2 +- .../hooks/tools/shared/useToolOperation.ts | 2 +- .../hooks/tools/shared/useViewScopedFiles.ts | 16 +- .../hooks/tools/showJS/useShowJSOperation.ts | 2 +- .../useValidateSignatureOperation.ts | 2 +- frontend/src/core/tools/Compare.tsx | 456 ++++++----------- frontend/src/core/tools/Merge.tsx | 9 +- 34 files changed, 1534 insertions(+), 845 deletions(-) create mode 100644 frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css create mode 100644 frontend/src/core/components/shared/FileSelectorPicker.module.css create mode 100644 frontend/src/core/components/shared/FileSelectorPicker.tsx diff --git a/frontend/.prettierignore b/frontend/.prettierignore index ad5dab21a1..6175a7b3a1 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,5 +1,7 @@ dist/ node_modules/ +src-tauri/target/ +src-tauri/gen/ public/vendor/ public/pdfjs*/ public/js/thirdParty/ diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 94248c215e..30eb7b949a 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -50,8 +50,8 @@ exportAndContinue = "Export & Continue" false = "False" fileSavedToDisk = "File saved to disk" fileNotSavedToDisk = "Not saved to disk" -fileSelected = "Selected: {{filename}}" -filesSelected = "{{count}} files selected" +fileSelected = "{{filename}}" +filesSelected = "{{count}} files" font = "Font" fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text." fontTypeTooltip = "Font family for the page numbers. Choose based on your document style." @@ -77,7 +77,7 @@ multiPdfPrompt = "Select PDFs (2+)" never = "Never" no = "No" noFavourites = "No favourites added" -noFileSelected = "No file selected. Please upload one." +noFileSelected = "No file loaded. Please upload one." noFilesToUndo = "Cannot undo: no files were processed in the last operation" noOperationToUndo = "No operation to undo" notAuthenticatedMessage = "User not authenticated." @@ -2681,6 +2681,7 @@ used = "used" [compare] addFilesHint = "Add PDFs in the Files step to enable selection." clearSelected = "Clear selected" +clearSlot = "Remove file" cta = "Compare" header = "Compare PDFs" loading = "Comparing..." @@ -2733,6 +2734,7 @@ title = "These PDFs look highly different" [compare.edited] label = "Edited PDF" placeholder = "Select the edited PDF" +selectBaseFirst = "Select original PDF first" [compare.error] filesMissing = "Unable to locate the selected files. Please re-select them." @@ -2788,6 +2790,7 @@ pageLabel = "Page" confirm = "Swap and Re-run" confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?" confirmTitle = "Re-run comparison?" +label = "Swap" [compare.toasts] unlinkedBody = "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." @@ -3030,7 +3033,7 @@ maintainAspectRatio = "Maintain Aspect Ratio" markdown = "Markdown" maxAttachmentSize = "Maximum attachment size (MB)" multiple = "Multiple" -noFileSelected = "No file selected. Use the file panel to add files." +noFileSelected = "No file loaded. Use the file panel to add files." odpExt = "OpenDocument Presentation (.odp)" odtExt = "OpenDocument Text (.odt)" officeDocs = "Office Documents (Word, Excel, PowerPoint)" @@ -3048,9 +3051,9 @@ pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will b pptExt = "PowerPoint (.pptx)" results = "Results" rtfExt = "Rich Text Format (.rtf)" -selectedFiles = "Selected files" -selectFilesPlaceholder = "Select files in the main view to get started" -selectSourceFormatFirst = "Select a source format first" +selectedFiles = "Active files" +selectFilesPlaceholder = "Add files to the workbench to get started" +selectSourceFormatFirst = "Choose a source format first" settings = "Settings" single = "Single" sourceFormatPlaceholder = "Source format" @@ -3142,7 +3145,7 @@ scarf = "Scarf Pixel" [crop] autoCrop = "Auto-crop whitespace" header = "Crop PDF" -noFileSelected = "Select a PDF file to begin cropping" +noFileSelected = "Add a PDF file to begin cropping" reset = "Reset to full PDF" submit = "Apply Crop" title = "Crop" @@ -3454,21 +3457,24 @@ or = "or" [fileEditor] addFiles = "Add Files" +pageCount = "{{count}} pages" +pageCount_one = "{{count}} page" +pageCount_other = "{{count}} pages" [fileManager] active = "Active" addToUpload = "Add to Upload" clearAll = "Clear All" -clearSelection = "Clear Selection" +clearSelection = "Clear" clickToUpload = "Click to upload files" closeFile = "Close File" delete = "Delete" deleteAll = "Delete All" -deleteSelected = "Delete Selected" -deselectAll = "Deselect All" +deleteSelected = "Delete Files" +deselectAll = "Uncheck All" details = "File Details" download = "Download" -downloadSelected = "Download Selected" +downloadSelected = "Download Files" dragDrop = "Drag & Drop files here" dropFilesHere = "Drop files here" failedToLoad = "Failed to load file to active set." @@ -3477,7 +3483,7 @@ fileFormat = "Format" fileHistory = "File History" fileName = "Name" fileSize = "Size" -filesSelected = "files selected" +filesSelected = "files" filesStored = "files stored" fileVersion = "Version" googleDrive = "Google Drive" @@ -3493,7 +3499,7 @@ mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" myFiles = "My Files" noFiles = "No files available" -noFileSelected = "No files selected" +noFileSelected = "No files chosen" noFilesFound = "No files found matching your search" noRecentFiles = "No recent files found" openFile = "Open File" @@ -3503,11 +3509,11 @@ openInPageEditor = "Open in Page Editor" recent = "Recent" reloadFiles = "Reload Files" restore = "Restore" -saveSelected = "Save Selected" +saveSelected = "Save Files" searchFiles = "Search files..." -selectAll = "Select All" -selectedCount = "{{count}} selected" -selectedFiles = "Selected Files" +selectAll = "Check All" +selectedCount = "{{count}} checked" +selectedFiles = "Files Added" showAll = "Show All" showHistory = "Show History" sortByDate = "Sort by Date" @@ -3521,7 +3527,7 @@ subtitle = "Add files to your storage for easy access across tools" supportMessage = "Powered by browser database storage for unlimited capacity" title = "Upload PDF Files" toolChain = "Tools Applied" -totalSelected = "Total Selected" +totalSelected = "Total Files" unsupported = "Unsupported" unzip = "Unzip" uploadError = "Failed to upload some files." @@ -3553,7 +3559,8 @@ makeCopy = "Make a copy" owner = "Owner" ownerUnknown = "Unknown" share = "Share" -shareSelected = "Share Selected" +shareSelected = "Share Files" +shared = "Shared" sharedByYou = "Shared by you" sharedEditNoticeBody = "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy." sharedEditNoticeConfirm = "Got it" @@ -3563,19 +3570,38 @@ sharing = "Sharing" storageState = "Storage" synced = "Synced" updateOnServer = "Update on Server" -uploadSelected = "Upload Selected" +uploadSelected = "Upload Files" uploadToServer = "Upload to Server" [files] addFiles = "Add files" created = "Created" -selectFromWorkbench = "Select files from the workbench or " -selectMultipleFromWorkbench = "Select at least {{count}} files from the workbench or " +selectFromWorkbench = "Add files to the workbench or " +selectMultipleFromWorkbench = "Add at least {{count}} files to the workbench or " size = "File Size" title = "Files" upload = "Upload" uploadFiles = "Upload Files" +[fileSelectorPicker] +empty = "No files available" +placeholder = "Select file" +tabListLabel = "Saved files, workbench, upload" +upload = "Upload from computer" + +[fileSelectorPicker.sort] +dateAsc = "Oldest first" +dateDesc = "Newest first" +dateLabel = "Latest" +nameAsc = "A to Z" +nameDesc = "Z to A" +nameLabel = "A–Z" + +[fileSelectorPicker.tabs] +saved = "Saved files" +workbench = "Workbench" +upload = "Upload" + [fileToPDF] credit = "This service uses LibreOffice and Unoconv for file conversion." header = "Convert any file to PDF" @@ -4429,7 +4455,7 @@ title = "Markdown To PDF" submit = "Merge" tags = "merge,Page operations,Back end,server side" title = "Merge" -viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them." +viewerModeHint = "Merge needs 2 or more files. Head to the file editor to add them." goToFileEditor = "Go to file editor" [merge.error] @@ -4453,7 +4479,7 @@ title = "Remove Digital Signature" ascending = "Ascending" dateModified = "Date Modified" descending = "Descending" -description = "Files will be merged in the order they're selected. Drag to reorder or sort below." +description = "Files will be merged in the order shown. Drag to reorder or sort below." filename = "File Name" label = "Sort By" sort = "Sort" @@ -4834,8 +4860,8 @@ text = "Foreground places the overlay on top of the page. Background places it b title = "Overlay Position" [pageEdit] -deselectAll = "Select None" -selectAll = "Select All" +deselectAll = "None" +selectAll = "Check All" [pageEditor] actualSize = "Actual Size" @@ -4889,6 +4915,7 @@ lineNumbers = "Line numbers" renderMarkdown = "Render markdown" [rightRail] +closeAll = "Close All Files" closeSelected = "Close Selected Files" selectAll = "Select All" deselectAll = "Deselect All" @@ -7405,10 +7432,10 @@ viewActivity = "View activity" viewed = "Viewed" viewsCount = "Views: {{count}}" downloaded = "Downloaded" -bulkDescription = "Create one link to share all selected files with signed-in users." -bulkTitle = "Share selected files" +bulkDescription = "Create one link to share all checked files with signed-in users." +bulkTitle = "Share checked files" copyLink = "Copy share link" -fileCount = "{{count}} files selected" +fileCount = "{{count}} files" ownerOnly = "Only the owner can manage sharing." selectSingleFile = "Select a single file to manage sharing." @@ -7422,9 +7449,9 @@ success = "Uploaded to server" title = "Upload to Server" updateButton = "Update on Server" uploadButton = "Upload to Server" -bulkDescription = "This uploads the selected files to your server storage." -bulkTitle = "Upload selected files" -fileCount = "{{count}} files selected" +bulkDescription = "This uploads the checked files to your server storage." +bulkTitle = "Upload checked files" +fileCount = "{{count}} files" more = " +{{count}} more" [storage] @@ -7546,11 +7573,10 @@ endpointUnavailable = "This tool is unavailable on your server." endpointUnavailableClickable = "Not available in this mode. Click to sign in." invalidParams = "Fill in the required settings." noFiles = "Add a file to get started." -viewerMode = "Switch to the file editor to select multiple files." +viewerMode = "Switch to the file editor to add multiple files." singleFileScope = "Only applying to: {{fileName}}" scopeThisFile = "this file" scopeFiles = "files" -selectFilesHint = "Select files in Active Files to run this tool" [tools] noSearchResults = "No tools found" diff --git a/frontend/src/core/components/FileManager.tsx b/frontend/src/core/components/FileManager.tsx index fe140ab173..3f7ade76bb 100644 --- a/frontend/src/core/components/FileManager.tsx +++ b/frontend/src/core/components/FileManager.tsx @@ -20,7 +20,7 @@ interface FileManagerProps { } const FileManager: React.FC = ({ selectedTool }) => { - const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext(); + const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect, maxSelectable } = useFilesModalContext(); const { config } = useAppConfig(); const [recentFiles, setRecentFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); @@ -196,6 +196,7 @@ const FileManager: React.FC = ({ selectedTool }) => { onClose={closeFilesModal} isFileSupported={isFileSupported} isOpen={isFilesModalOpen} + maxSelectable={maxSelectable} onFileRemove={handleRemoveFileByIndex} modalHeight={modalHeight} refreshRecentFiles={refreshRecentFiles} diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index 64a64b6c46..ab5b2e9c2c 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -1,7 +1,7 @@ -import { useState, useCallback, useRef, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { Dropzone } from "@mantine/dropzone"; -import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext"; +import { useFileSelection, useFileState, useFileManagement, useFileActions } from "@app/contexts/FileContext"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { zipFileService } from "@app/services/zipFileService"; @@ -36,14 +36,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); const { actions: fileActions } = useFileActions(); - const { actions: fileContextActions } = useFileContext(); - const { clearAllFileErrors } = fileContextActions; + const { selectedFileIds, setSelectedFiles } = useFileSelection(); // Extract needed values from state (memoized to prevent infinite loops) const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [state.files.byId, state.files.ids]); - const selectedFileIds = state.ui.selectedFileIds; - const totalItems = state.files.ids.length; - const selectedCount = selectedFileIds.length; // Get navigation actions const { actions: navActions } = useNavigationActions(); @@ -51,9 +47,6 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi // Get viewer context for setting active file index and ID const { setActiveFileIndex, setActiveFileId } = useViewer(); - // Get file selection context - const { setSelectedFiles } = useFileSelection(); - const [_status, _setStatus] = useState(null); const [_error, _setError] = useState(null); @@ -64,7 +57,6 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi const showError = useCallback((message: string) => { alert({ alertType: "error", title: "Error", body: message, expandable: true }); }, []); - const [selectionMode, setSelectionMode] = useState(toolMode); // Current tool (for enforcing maxFiles limits) const { selectedTool } = useToolWorkflow(); @@ -75,60 +67,18 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax; }, [selectedTool?.maxFiles, toolMode]); - // Enable selection mode automatically in tool mode - useEffect(() => { - if (toolMode) { - setSelectionMode(true); - } - }, [toolMode]); const [showFilePickerModal, setShowFilePickerModal] = useState(false); - // Get selected file IDs from context (defensive programming) - const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; - // Create refs for frequently changing values to stabilize callbacks - const contextSelectedIdsRef = useRef([]); - contextSelectedIdsRef.current = contextSelectedIds; - - // Use activeStirlingFileStubs directly - no conversion needed - const localSelectedIds = contextSelectedIds; - - const handleSelectAllFiles = useCallback(() => { - // Respect maxAllowed: if limited, select the last N files - const allIds = state.files.ids; - const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds; - setSelectedFiles(idsToSelect); - try { - clearAllFileErrors(); - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.warn("Failed to clear file errors on select all:", error); - } - } - }, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]); - - const handleDeselectAllFiles = useCallback(() => { - setSelectedFiles([]); - try { - clearAllFileErrors(); - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.warn("Failed to clear file errors on deselect:", error); - } - } - }, [setSelectedFiles, clearAllFileErrors]); - - const handleCloseSelectedFiles = useCallback(() => { - if (selectedFileIds.length === 0) return; - void removeFiles(selectedFileIds, false); - setSelectedFiles([]); - }, [selectedFileIds, removeFiles, setSelectedFiles]); + const handleCloseAllFiles = useCallback(() => { + void removeFiles( + activeStirlingFileStubs.map((r) => r.id), + false, + ); + }, [activeStirlingFileStubs, removeFiles]); useFileEditorRightRailButtons({ - totalItems, - selectedCount, - onSelectAll: handleSelectAllFiles, - onDeselectAll: handleDeselectAllFiles, - onCloseSelected: handleCloseSelectedFiles, + totalItems: activeStirlingFileStubs.length, + onCloseAll: handleCloseAllFiles, }); // Process uploaded files using context @@ -163,46 +113,6 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles], ); - const toggleFile = useCallback( - (fileId: FileId) => { - const currentSelectedIds = contextSelectedIdsRef.current; - - const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId); - if (!targetRecord) return; - - const contextFileId = fileId; // No need to create a new ID - const isSelected = currentSelectedIds.includes(contextFileId); - - let newSelection: FileId[]; - - if (isSelected) { - // Remove file from selection - newSelection = currentSelectedIds.filter((id) => id !== contextFileId); - } else { - // Add file to selection - // Determine max files allowed from the active tool (negative or undefined means unlimited) - const rawMax = selectedTool?.maxFiles; - const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax; - - if (maxAllowed === 1) { - // Only one file allowed -> replace selection with the new file - newSelection = [contextFileId]; - } else { - // If at capacity, drop the oldest selected and append the new one - if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) { - newSelection = [...currentSelectedIds.slice(1), contextFileId]; - } else { - newSelection = [...currentSelectedIds, contextFileId]; - } - } - } - - // Update context (this automatically updates tool selection since they use the same action) - setSelectedFiles(newSelection); - }, - [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles], - ); - // Enforce maxAllowed when tool changes or when an external action sets too many selected files useEffect(() => { if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) { @@ -441,12 +351,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi file={record} index={index} totalFiles={activeStirlingFileStubs.length} - selectedFiles={localSelectedIds} - selectionMode={selectionMode} - onToggleFile={toggleFile} onCloseFile={handleCloseFile} onViewFile={handleViewFile} - _onSetStatus={showStatus} onReorderFiles={handleReorderFiles} onDownloadFile={handleDownloadFile} onUnzipFile={handleUnzipFile} diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css new file mode 100644 index 0000000000..af54dc6cc9 --- /dev/null +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css @@ -0,0 +1,246 @@ +.card { + width: 220px; + max-width: 220px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 10px 10px 12px; + position: relative; + --file-text-height: 58px; + --file-text-bottom: 12px; + --thumb-text-gap: 6px; + padding-bottom: calc(var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap)); + user-select: none; + cursor: pointer; +} + +/* Inner wrapper — centers [toolChain + thumbnail + hover menu] as a unit */ +.thumbInner { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + width: 100%; + max-width: 100%; +} + +/* Groups toolchain bar + thumbnail so they center together inside thumbWrap */ +.thumbUnit { + display: flex; + flex-direction: column; + align-items: center; + max-height: 100%; +} + +/* ToolChain bar sits directly above the thumbnail card — always rendered for consistent sizing */ +.toolChainBar { + flex-shrink: 0; + width: 100%; + height: 22px; + text-align: center; + overflow: hidden; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + line-height: 22px; + margin-bottom: 4px; +} + +.card:hover .toolChainBar, +.card:focus-within .toolChainBar { + opacity: 1; +} + +.thumbWrap { + width: 100%; + position: relative; + height: 260px; + display: flex; + align-items: center; + justify-content: center; +} + +.thumbContainer { + aspect-ratio: var(--thumb-aspect, 8.5 / 11); + width: 100%; + max-width: 100%; + max-height: 100%; + border-radius: 12px; + box-shadow: var(--shadow-md); + overflow: hidden; + position: relative; + background: var(--bg-surface); + transition: + box-shadow 0.15s ease, + transform 0.2s ease; +} + +.thumbContainer[data-supported="false"] { + filter: grayscale(80%); + opacity: 0.65; +} + +.thumbContainer:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-lg); +} + +.thumbImage { + width: 100%; + height: 100%; + object-fit: contain; + border-radius: 12px; + background: var(--bg-surface); + display: block; + position: relative; + z-index: 1; +} + +/* Text block pinned below thumbnail */ +.fileText { + position: absolute; + left: 10px; + right: 10px; + bottom: var(--file-text-bottom); + height: var(--file-text-height); + text-align: center; + pointer-events: none; + --file-name-line-height: 18px; + --file-name-lines: 2; + --file-meta-line-height: 14px; + --file-meta-gap: 4px; +} + +.fileName { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + line-height: var(--file-name-line-height); + height: calc(var(--file-name-lines) * var(--file-name-line-height)); + margin: 0; + position: absolute; + left: 0; + right: 0; + bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap)); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + overflow-wrap: break-word; + word-break: normal; +} + +.fileMeta { + font-size: 11px; + color: var(--text-secondary); + line-height: var(--file-meta-line-height); + height: var(--file-meta-line-height); + overflow: hidden; + margin: 0; + position: absolute; + left: 0; + right: 0; + bottom: 0; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* CSS-hover driven menu visibility */ +.card [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] { + opacity: 0; + pointer-events: none; +} + +.card:hover [data-hover-action-menu-mode="cssHover"][data-force-visible="false"], +.card:focus-within [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] { + opacity: 1; + pointer-events: auto; +} + +/* Error overlay */ +.errorOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(220, 38, 38, 0.12); + border-radius: 12px; + z-index: 2; + pointer-events: none; +} + +.errorPill { + background: var(--mantine-color-red-6); + color: white; + border-radius: 12px; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; +} + +/* Badges row pinned to top-left of thumbnail - always visible */ +.thumbBadges { + position: absolute; + top: 6px; + left: 6px; + display: flex; + gap: 4px; + align-items: center; + z-index: 3; + pointer-events: none; +} + +.ownershipBadge { + background: var(--mantine-color-blue-6); + color: white; + border-radius: 6px; + padding: 2px 7px; + font-size: 10px; + font-weight: 600; + white-space: nowrap; +} + +.versionBadgeThumb { + background: var(--mantine-color-blue-6); + border-radius: 4px; + padding: 1px 5px; + font-size: 10px; + font-weight: 700; + color: white; + line-height: 16px; + white-space: nowrap; +} + +.pinnedBadge { + background: var(--mantine-color-yellow-6); + color: white; + border-radius: 4px; + padding: 2px 4px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.dragHandle { + position: absolute; + bottom: 6px; + right: 6px; + cursor: grab; + color: var(--text-secondary); + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +.card:hover .dragHandle, +.card:focus-within .dragHandle { + opacity: 0.5; + pointer-events: auto; +} diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index 307b498f26..47ad20c0c9 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useRef, useMemo } from "react"; -import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core"; +import { Text, Modal, Button, Group, Stack, Loader, ActionIcon, Tooltip } from "@mantine/core"; import { useIsMobile } from "@app/hooks/useIsMobile"; import { alert } from "@app/components/toast"; import { useTranslation } from "react-i18next"; @@ -11,18 +11,16 @@ import UnarchiveIcon from "@mui/icons-material/Unarchive"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import LinkIcon from "@mui/icons-material/Link"; import PushPinIcon from "@mui/icons-material/PushPin"; -import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { StirlingFileStub } from "@app/types/fileContext"; import { zipFileService } from "@app/services/zipFileService"; -import styles from "@app/components/fileEditor/FileEditor.module.css"; +import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css"; import { useFileContext } from "@app/contexts/FileContext"; import { useFileState } from "@app/contexts/file/fileHooks"; import { FileId } from "@app/types/file"; -import { formatFileSize } from "@app/utils/fileUtils"; import ToolChain from "@app/components/shared/ToolChain"; import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu"; import { downloadFile } from "@app/services/downloadService"; @@ -36,12 +34,8 @@ interface FileEditorThumbnailProps { file: StirlingFileStub; index: number; totalFiles: number; - selectedFiles: FileId[]; - selectionMode: boolean; - onToggleFile: (fileId: FileId) => void; onCloseFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; - _onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; onDownloadFile: (fileId: FileId) => void; onUnzipFile?: (fileId: FileId) => void; @@ -51,12 +45,8 @@ interface FileEditorThumbnailProps { const FileEditorThumbnail = ({ file, - index, - selectedFiles, - onToggleFile, onCloseFile, onViewFile, - _onSetStatus, onReorderFiles, onDownloadFile, onUnzipFile, @@ -69,40 +59,24 @@ const FileEditorThumbnail = ({ const DownloadOutlinedIcon = icons.download; const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext(); const { state, selectors } = useFileState(); - const hasError = state.ui.errorFileIds.includes(file.id); - - // ---- Drag state ---- - const [isDragging, setIsDragging] = useState(false); - const dragElementRef = useRef(null); - const [showHoverMenu, setShowHoverMenu] = useState(false); const isMobile = useIsMobile(); - const [showCloseModal, setShowCloseModal] = useState(false); - const [showUploadModal, setShowUploadModal] = useState(false); - const [showShareModal, setShowShareModal] = useState(false); - const [showSharedEditNotice, setShowSharedEditNotice] = useState(false); - const sharedEditNoticeShownRef = useRef(false); - // Resolve the actual File object for pin/unpin operations - const actualFile = useMemo(() => { - return activeFiles.find((f) => f.fileId === file.id); - }, [activeFiles, file.id]); + const actualFile = useMemo(() => activeFiles.find((f) => f.fileId === file.id), [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; - // Check if this is a ZIP file const isZipFile = zipFileService.isZipFileStub(file); + const hasError = state.ui.errorFileIds.includes(file.id); const pageCount = file.processedFile?.totalPages || 0; const isEncrypted = Boolean(file.processedFile?.isEncrypted); + // Flip aspect ratio for 90°/270° rotated PDFs so the card shape matches the page orientation + const firstPageRotation = file.processedFile?.pages?.[0]?.rotation ?? 0; + const isLandscape = firstPageRotation === 90 || firstPageRotation === 270; + const thumbAspect = isLandscape ? "11 / 8.5" : "8.5 / 11"; + const handleRef = useRef(null); - - // ---- Selection ---- - const isSelected = selectedFiles.includes(file.id); - - // ---- Meta formatting ---- - const prettySize = useMemo(() => { - return formatFileSize(file.size); - }, [file.size]); + const dragElementRef = useRef(null); const extUpper = useMemo(() => { const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ""); @@ -116,6 +90,7 @@ const FileEditorThumbnail = ({ const isCBZ = extLower === "cbz"; const isCBR = extLower === "cbr"; + const uploadEnabled = config?.storageEnabled === true; const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; @@ -125,6 +100,7 @@ const FileEditorThumbnail = ({ const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; const isUploaded = Boolean(file.remoteStorageId); const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; + const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate); const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf; @@ -140,7 +116,13 @@ const FileEditorThumbnail = ({ }).format(d); }, [file.lastModified]); - // ---- Drag & drop wiring ---- + const [isDragging, setIsDragging] = useState(false); + const [showCloseModal, setShowCloseModal] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + const [showShareModal, setShowShareModal] = useState(false); + const [showSharedEditNotice, setShowSharedEditNotice] = useState(false); + const sharedEditNoticeShownRef = useRef(false); + const fileElementRef = useCallback( (element: HTMLDivElement | null) => { if (!element) return; @@ -153,32 +135,19 @@ const FileEditorThumbnail = ({ type: "file", fileId: file.id, fileName: file.name, - selectedFiles: [file.id], // Always drag only this file, ignore selection state + selectedFiles: [file.id], }), - onDragStart: () => { - setIsDragging(true); - }, - onDrop: () => { - setIsDragging(false); - }, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), }); const dropCleanup = dropTargetForElements({ element, - getData: () => ({ - type: "file", - fileId: file.id, - }), - canDrop: ({ source }) => { - const sourceData = source.data; - return sourceData.type === "file" && sourceData.fileId !== file.id; - }, + getData: () => ({ type: "file", fileId: file.id }), + canDrop: ({ source }) => source.data.type === "file" && source.data.fileId !== file.id, onDrop: ({ source }) => { - const sourceData = source.data; - if (sourceData.type === "file" && onReorderFiles) { - const sourceFileId = sourceData.fileId as FileId; - const selectedFileIds = sourceData.selectedFiles as FileId[]; - onReorderFiles(sourceFileId, file.id, selectedFileIds); + if (source.data.type === "file" && onReorderFiles) { + onReorderFiles(source.data.fileId as FileId, file.id, source.data.selectedFiles as FileId[]); } }, }); @@ -188,13 +157,11 @@ const FileEditorThumbnail = ({ dropCleanup(); }; }, - [file.id, file.name, selectedFiles, onReorderFiles], + [file.id, file.name, onReorderFiles], ); - // Handle close with confirmation - const handleCloseWithConfirmation = useCallback(() => { - setShowCloseModal(true); - }, []); + const handleCloseWithConfirmation = useCallback(() => setShowCloseModal(true), []); + const handleCancelClose = useCallback(() => setShowCloseModal(false), []); const handleConfirmClose = useCallback(() => { onCloseFile(file.id); @@ -227,17 +194,11 @@ const FileEditorThumbnail = ({ return; } } - // Then close onCloseFile(file.id); alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 }); setShowCloseModal(false); }, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]); - const handleCancelClose = useCallback(() => { - setShowCloseModal(false); - }, []); - - // Build hover menu actions const hoverActions = useMemo( () => [ { @@ -249,6 +210,25 @@ const FileEditorThumbnail = ({ onViewFile(file.id); }, }, + { + id: "pin", + icon: , + label: isPinned + ? t("unpin", "Unpin File (replace after tool run)") + : t("pin", "Pin File (keep active after tool run)"), + onClick: (e) => { + e.stopPropagation(); + if (actualFile) { + if (isPinned) { + unpinFile(actualFile); + alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); + } else { + pinFile(actualFile); + alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); + } + } + }, + }, { id: "download", icon: , @@ -258,36 +238,32 @@ const FileEditorThumbnail = ({ onDownloadFile(file.id); }, }, - ...(canUpload || canShare + ...(canUpload ? [ - ...(canUpload - ? [ - { - id: "upload", - icon: , - label: isUploaded - ? t("fileManager.updateOnServer", "Update on Server") - : t("fileManager.uploadToServer", "Upload to Server"), - onClick: (e: React.MouseEvent) => { - e.stopPropagation(); - setShowUploadModal(true); - }, - }, - ] - : []), - ...(canShare - ? [ - { - id: "share", - icon: , - label: t("fileManager.share", "Share"), - onClick: (e: React.MouseEvent) => { - e.stopPropagation(); - setShowShareModal(true); - }, - }, - ] - : []), + { + id: "upload", + icon: , + label: isUploaded + ? t("fileManager.updateOnServer", "Update on Server") + : t("fileManager.uploadToServer", "Upload to Server"), + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + setShowUploadModal(true); + }, + }, + ] + : []), + ...(canShare + ? [ + { + id: "share", + icon: , + label: t("fileManager.share", "Share"), + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + setShowShareModal(true); + }, + }, ] : []), { @@ -321,7 +297,10 @@ const FileEditorThumbnail = ({ isZipFile, isCBZ, isCBR, + isPinned, + actualFile, terminology, + DownloadOutlinedIcon, onViewFile, onDownloadFile, onUnzipFile, @@ -329,13 +308,13 @@ const FileEditorThumbnail = ({ canUpload, canShare, isUploaded, + pinFile, + unpinFile, ], ); - // ---- Card interactions ---- const handleCardClick = () => { if (!isSupported) return; - // Clear error state if file has an error (click to clear error) if (hasError) { try { fileActions.clearFileError(file.id); @@ -347,7 +326,6 @@ const FileEditorThumbnail = ({ sharedEditNoticeShownRef.current = true; setShowSharedEditNotice(true); } - onToggleFile(file.id); }; const handleCardDoubleClick = () => { @@ -355,12 +333,9 @@ const FileEditorThumbnail = ({ onViewFile(file.id); }; - // ---- Style helpers ---- - const getHeaderClassName = () => { - if (hasError) return styles.headerError; - if (!isSupported) return styles.headerUnsupported; - return isSelected ? styles.headerSelected : styles.headerResting; - }; + const metaLine = [`v${file.versionNumber}`, dateLabel, extUpper ? `${extUpper} file` : "", pageLabel] + .filter(Boolean) + .join(" - "); return (
setShowHoverMenu(true)} - onMouseLeave={() => setShowHoverMenu(false)} onDoubleClick={handleCardDoubleClick} > - {/* Header bar */} -
- {/* Logo/checkbox area */} -
- {hasError ? ( -
- {t("error._value", "Error")} +
+ {/* Thumbnail area */} +
+ {/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */} +
+ {/* Tool chain bar — always rendered for consistent height, content only when history exists */} +
+ {file.toolHistory && file.toolHistory.length > 0 && ( + + )}
- ) : isSupported ? ( - onToggleFile(file.id)} - color="var(--checkbox-checked-bg)" - /> - ) : ( -
- {t("unsupported", "Unsupported")} -
- )} -
- {/* Centered index */} -
- {index + 1} -
- - {/* Action buttons group */} -
- {isEncrypted && ( - - { - e.stopPropagation(); - openEncryptedUnlockPrompt(file.id); - }} - > - - - - )} - {/* Pin/Unpin icon */} - - { - e.stopPropagation(); - if (actualFile) { - if (isPinned) { - unpinFile(actualFile); - alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); - } else { - pinFile(actualFile); - alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); - } - } - }} +
- {isPinned ? : } - - -
-
+ {/* Error overlay */} + {hasError && ( +
+ {t("error._value", "Error")} +
+ )} - {/* Title + meta line */} -
- - {truncateCenter(file.name, 40)} - - - {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} - {`v${file.versionNumber} - `} - {dateLabel} - {extUpper ? ` - ${extUpper} file` : ""} - {pageLabel ? ` - ${pageLabel}` : ""} - -
+ {/* Thumbnail image or loading state */} + {file.thumbnailUrl ? ( + + {file.name} { + e.currentTarget.style.display = "none"; + }} + /> + + ) : file.type?.startsWith("application/pdf") ? ( + + + + Loading thumbnail... + + + ) : null} - {/* Preview area */} -
-
- {file.thumbnailUrl ? ( - - {file.name} { - const img = e.currentTarget; - img.style.display = "none"; - img.parentElement?.setAttribute("data-thumb-missing", "true"); - }} - style={{ - maxWidth: "80%", - maxHeight: "80%", - objectFit: "contain", - borderRadius: 0, - background: "#ffffff", - border: "1px solid var(--border-default)", - display: "block", - marginLeft: "auto", - marginRight: "auto", - alignSelf: "start", - }} - /> - - ) : file.type?.startsWith("application/pdf") ? ( - - - - Loading thumbnail... - - - ) : null} -
- - {/* Drag handle (span wrapper so we can attach a ref reliably) */} - - - - - {/* Tool chain display at bottom */} - {file.toolHistory && ( -
- + {/* Badges — visible on hover via CSS */} +
+ v{file.versionNumber} + {isPinned && ( + + + + )} + {isSharedFile && !isOwnedOrLocal && ( + {t("fileManager.sharedWithYou", "Shared")} + )} + {isEncrypted && ( + + { + e.stopPropagation(); + openEncryptedUnlockPrompt(file.id); + }} + style={{ pointerEvents: "auto" }} + > + + + + )} +
+
- )} + {/* end thumbUnit */} + + {/* Drag handle */} + + + +
+ + {/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */} +
- {/* Hover Menu */} - + {/* File name + meta */} +
+

+ {truncateCenter(file.name, 40)} +

+

{metaLine}

+
{/* Close Confirmation Modal */} {t("confirmCloseUnsaved", "This file has unsaved changes.")} - {file.name} + {file.name} + + + {(fbProps) => ( + + )} + +
+
+ +
+ + +
+
+ + + {savedLoading ? ( +
+ +
+ ) : displayStubs.length === 0 ? ( +
+ {t("fileSelectorPicker.empty", "No files available")} +
+ ) : ( + displayStubs.map((stub) => { + const meta = buildMeta(stub); + const isItemLoading = loadingId === stub.id; + return ( + + ); + }) + )} +
+ + + ); +} diff --git a/frontend/src/core/components/shared/HoverActionMenu.tsx b/frontend/src/core/components/shared/HoverActionMenu.tsx index 3fc257716a..e617d3c792 100644 --- a/frontend/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/src/core/components/shared/HoverActionMenu.tsx @@ -18,19 +18,39 @@ interface HoverActionMenuProps { actions: HoverAction[]; position?: "inside" | "outside"; className?: string; + visibility?: "state" | "cssHover"; } -const HoverActionMenu: React.FC = ({ show, actions, position = "inside", className = "" }) => { +const HoverActionMenu: React.FC = ({ + show, + actions, + position = "inside", + className = "", + visibility = "state", +}) => { const visibleActions = actions.filter((action) => !action.hidden); if (visibleActions.length === 0) { return null; } + const style: React.CSSProperties = { zIndex: Z_INDEX_HOVER_ACTION_MENU }; + if (visibility === "state") { + style.opacity = show ? 1 : 0; + style.pointerEvents = show ? "auto" : "none"; + } else if (show) { + // Force visible (e.g. mobile) even when CSS-hover mode is active. + style.opacity = 1; + style.pointerEvents = "auto"; + } + return (
e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} diff --git a/frontend/src/core/components/shared/MultiSelectControls.tsx b/frontend/src/core/components/shared/MultiSelectControls.tsx index 856c971130..a35d72bf8f 100644 --- a/frontend/src/core/components/shared/MultiSelectControls.tsx +++ b/frontend/src/core/components/shared/MultiSelectControls.tsx @@ -26,11 +26,11 @@ const MultiSelectControls = ({ - {selectedCount} {t("fileManager.filesSelected", "files selected")} + {selectedCount} {t("fileManager.filesSelected", "files")} {onAddToUpload && ( diff --git a/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx b/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx index ba7c171c83..cc377cce58 100644 --- a/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx +++ b/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx @@ -39,9 +39,9 @@ const CreateSessionPanel = ({ ) : ( <>
-
{t("quickAccess.selectedFile", "Selected file")}
+
{t("quickAccess.selectedFile", "File")}
- {selectedFiles[0]?.name || t("quickAccess.noFile", "No file selected")} + {selectedFiles[0]?.name || t("quickAccess.noFile", "No file loaded")}
diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx index 7a1e7c21c8..bfd9e1385c 100644 --- a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -10,6 +10,9 @@ import { } from "@app/components/tools/compare/compare"; import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown"; import { useIsMobile } from "@app/hooks/useIsMobile"; +import { truncateCenter } from "@app/utils/textUtils"; + +const COMPARE_PANE_TITLE_MAX_LEN = 48; // utilities moved to compare.ts @@ -91,9 +94,9 @@ const CompareDocumentPane = ({ return (
- - - {title} + + + {title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title} {(changes.length > 0 || Boolean(dropdownPlaceholder)) && ( diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css index d249a7a088..fdb1d6870a 100644 --- a/frontend/src/core/components/tools/compare/compareView.css +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -516,3 +516,95 @@ margin-right: 0 !important; } } + +/* Compare tool — file selection step (Original / Edited + swap column) */ +.compare-step-selection { + width: 100%; +} + +.compare-step-selection__clear-row { + display: flex; + justify-content: flex-end; + width: 100%; +} + +/* Thumbnails + swap only — swap top aligns with first thumbnail, not “Original PDF” heading */ +.compare-step-selection__thumbs-row { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0.75rem; + width: 100%; + min-width: 0; +} + +.compare-step-selection__thumbs-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Full-height light bar to the right of both cards (reference UI) */ +.compare-step-selection__swap { + flex-shrink: 0; + width: 36px; + min-width: 36px; + align-self: stretch; + margin: 0; + padding: 0.5rem 0.2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + border-radius: var(--radius-md); + border: 1px solid color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default)); + background: color-mix(in srgb, var(--mantine-color-blue-0) 92%, var(--mantine-color-body)); + color: var(--mantine-color-blue-filled); + cursor: pointer; + font: inherit; + transition: + background 0.12s ease, + border-color 0.12s ease; +} + +.compare-step-selection__swap:hover:not(:disabled) { + background: color-mix(in srgb, var(--mantine-color-blue-1) 75%, var(--mantine-color-body)); + border-color: color-mix(in srgb, var(--mantine-color-blue-5) 35%, var(--border-default)); +} + +.compare-step-selection__swap:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.compare-step-selection__swap-icon { + font-size: 1.35rem !important; + color: var(--mantine-color-blue-filled); +} + +.compare-step-selection__swap-label { + font-size: 0.7rem; + font-weight: 600; + line-height: 1.15; + text-align: center; + color: var(--mantine-color-blue-filled); + writing-mode: horizontal-tb; +} + +[data-mantine-color-scheme="dark"] .compare-step-selection__swap { + background: color-mix(in srgb, var(--mantine-color-blue-9) 22%, var(--mantine-color-dark-6)); + border-color: color-mix(in srgb, var(--mantine-color-blue-4) 40%, var(--border-default)); + color: var(--mantine-color-blue-3); +} + +[data-mantine-color-scheme="dark"] .compare-step-selection__swap:hover:not(:disabled) { + background: color-mix(in srgb, var(--mantine-color-blue-9) 32%, var(--mantine-color-dark-6)); +} + +[data-mantine-color-scheme="dark"] .compare-step-selection__swap-icon, +[data-mantine-color-scheme="dark"] .compare-step-selection__swap-label { + color: var(--mantine-color-blue-3); +} diff --git a/frontend/src/core/components/tools/crop/CropSettings.tsx b/frontend/src/core/components/tools/crop/CropSettings.tsx index c461cf3688..355fc3047c 100644 --- a/frontend/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/src/core/components/tools/crop/CropSettings.tsx @@ -3,7 +3,7 @@ import { Stack, Text, Box, Group, ActionIcon, Center, Alert, Checkbox } from "@m import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters"; -import { useSelectedFiles } from "@app/contexts/file/fileHooks"; +import { useAllFiles } from "@app/contexts/FileContext"; import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector"; import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs"; import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants"; @@ -21,17 +21,17 @@ const CONTAINER_SIZE = 250; // Fit within actual pane width const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { const { t } = useTranslation(); - const { selectedFiles, selectedFileStubs } = useSelectedFiles(); + const { files, fileStubs } = useAllFiles(); - // Get the first selected file for preview + // Get the first file for preview const selectedStub = useMemo(() => { - return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null; - }, [selectedFileStubs]); + return fileStubs.length > 0 ? fileStubs[0] : null; + }, [fileStubs]); - // Get the first selected file for PDF processing + // Get the first file for PDF processing const selectedFile = useMemo(() => { - return selectedFiles.length > 0 ? selectedFiles[0] : null; - }, [selectedFiles]); + return files.length > 0 ? files[0] : null; + }, [files]); // Get thumbnail for the selected file const [thumbnail, setThumbnail] = useState(null); diff --git a/frontend/src/core/components/tools/rotate/RotateSettings.tsx b/frontend/src/core/components/tools/rotate/RotateSettings.tsx index e7197feec3..51c803aec0 100644 --- a/frontend/src/core/components/tools/rotate/RotateSettings.tsx +++ b/frontend/src/core/components/tools/rotate/RotateSettings.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters"; -import { useSelectedFiles } from "@app/contexts/file/fileHooks"; +import { useAllFiles } from "@app/contexts/FileContext"; import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; interface RotateSettingsProps { @@ -14,12 +14,12 @@ interface RotateSettingsProps { const RotateSettings = ({ parameters, disabled = false }: RotateSettingsProps) => { const { t } = useTranslation(); - const { selectedFileStubs } = useSelectedFiles(); + const { fileStubs } = useAllFiles(); - // Get the first selected file for preview + // Get the first file for preview const selectedStub = useMemo(() => { - return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null; - }, [selectedFileStubs]); + return fileStubs.length > 0 ? fileStubs[0] : null; + }, [fileStubs]); // Get thumbnail for the selected file const [thumbnail, setThumbnail] = useState(null); diff --git a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx index 4570418ed6..05cac5da5a 100644 --- a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx @@ -56,9 +56,9 @@ const FileStatusIndicator = ({ selectedFiles = [], minFiles = 1 }: FileStatusInd const getPlaceholder = () => { if (minFiles === undefined || minFiles === 1) { - return t("files.selectFromWorkbench", "Select files from the workbench or "); + return t("files.selectFromWorkbench", "Add files to the workbench or "); } else { - return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { + return t("files.selectMultipleFromWorkbench", "Add at least {{count}} files to the workbench or ", { count: minFiles, }); } @@ -136,9 +136,9 @@ const FileStatusIndicator = ({ selectedFiles = [], minFiles = 1 }: FileStatusInd ✓{" "} {selectedFiles.length === 1 ? ( - {t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name })} + {t("fileSelected", "{{filename}}", { filename: selectedFiles[0]?.name })} ) : ( - t("filesSelected", "{{count}} files selected", { count: selectedFiles.length }) + t("filesSelected", "{{count}} files", { count: selectedFiles.length }) )} ); diff --git a/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx b/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx index 55f083e571..ca3d0a96b1 100644 --- a/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx +++ b/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx @@ -16,8 +16,7 @@ export interface ScopedOperationButtonProps extends OperationButtonProps { * * - Viewer mode (multiple files loaded): appends "(this file)" to button text and * shows a note naming the exact file that will be processed. - * - File-editor mode with N>1 selected files: appends "(N files)" to button text. - * - File-editor mode with 0 selected files: shows a hint to select files. + * - N>1 files loaded: appends "(N files)" to button text. * - All other cases: no change to button text or layout. */ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...props }: ScopedOperationButtonProps) { @@ -27,7 +26,6 @@ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...pro const { files: allFiles } = useAllFiles(); const isViewerMode = workbench === "viewer"; - const isFileEditorMode = workbench === "fileEditor"; const hasMultipleFilesLoaded = allFiles.length > 1; const baseText = props.submitText ?? t("submit", "Submit"); @@ -47,8 +45,6 @@ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...pro ? allFiles[activeFileIndex]?.name : null; - const showSelectFilesHint = !disableScopeHints && isFileEditorMode && allFiles.length > 0 && selectedFiles.length === 0; - return ( <> @@ -57,11 +53,6 @@ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...pro {t("tool.singleFileScope", "Only applying to: {{fileName}}", { fileName: viewerFileName })} )} - {showSelectFilesHint && ( - - {t("tool.selectFilesHint", "Select files in Active Files to run this tool")} - - )} ); } diff --git a/frontend/src/core/components/viewer/NonPdfViewer.tsx b/frontend/src/core/components/viewer/NonPdfViewer.tsx index 214ac3fc03..8c4fc17160 100644 --- a/frontend/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/src/core/components/viewer/NonPdfViewer.tsx @@ -99,7 +99,7 @@ export function NonPdfViewerWrapper(props: ViewerProps) { return (
- No file selected + No file loaded
); diff --git a/frontend/src/core/contexts/FileManagerContext.tsx b/frontend/src/core/contexts/FileManagerContext.tsx index 5f7cc98baf..e42558d86d 100644 --- a/frontend/src/core/contexts/FileManagerContext.tsx +++ b/frontend/src/core/contexts/FileManagerContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from "react"; import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { fileStorage } from "@app/services/fileStorage"; -import { useFileActions } from "@app/contexts/FileContext"; +import { useFileActions, useFileManagement } from "@app/contexts/FileContext"; import { zipFileService } from "@app/services/zipFileService"; import { StirlingFileStub } from "@app/types/fileContext"; import { downloadFiles } from "@app/utils/downloadUtils"; @@ -78,6 +78,7 @@ interface FileManagerProviderProps { refreshRecentFiles: () => Promise; isLoading: boolean; activeFileIds: FileId[]; + maxSelectable?: number | null; } type RemoteDeleteChoice = "local" | "server" | "both" | "leave" | "cancel"; @@ -95,10 +96,11 @@ export const FileManagerProvider: React.FC = ({ refreshRecentFiles, isLoading, activeFileIds, + maxSelectable = null, }) => { const [activeSource, setActiveSource] = useState<"recent" | "local" | "drive">("recent"); const [storageFilter, setStorageFilter] = useState<"all" | "local" | "sharedWithMe" | "sharedByMe">("all"); - const [selectedFileIds, setSelectedFileIds] = useState([]); + const [selectedFileIds, setSelectedFileIds] = useState(() => activeFileIds); const [searchTerm, setSearchTerm] = useState(""); const [lastClickedIndex, setLastClickedIndex] = useState(null); const [expandedFileIds, setExpandedFileIds] = useState>(new Set()); @@ -108,6 +110,14 @@ export const FileManagerProvider: React.FC = ({ const deletePromptResolveRef = useRef<((choice: RemoteDeleteChoice) => void) | null>(null); const { t } = useTranslation(); const { actions } = useFileActions(); + const { removeFiles } = useFileManagement(); + + // Re-seed selection with active file IDs each time the modal opens + useEffect(() => { + if (isOpen) { + setSelectedFileIds(activeFileIds); + } + }, [isOpen]); // Track blob URLs for cleanup const createdBlobUrls = useRef>(new Set()); @@ -234,6 +244,11 @@ export const FileManagerProvider: React.FC = ({ selectedSet.delete(fileId); } else { selectedSet.add(fileId); + // Enforce maxSelectable: drop the oldest selection if over the limit + if (maxSelectable != null && selectedSet.size > maxSelectable) { + const [oldest] = selectedSet; + selectedSet.delete(oldest); + } } return Array.from(selectedSet); @@ -243,7 +258,7 @@ export const FileManagerProvider: React.FC = ({ setLastClickedIndex(currentIndex); } }, - [filteredFiles, lastClickedIndex], + [filteredFiles, lastClickedIndex, maxSelectable], ); // Helper function to safely determine which files can be deleted @@ -551,11 +566,20 @@ export const FileManagerProvider: React.FC = ({ ); const handleOpenFiles = useCallback(() => { - if (selectedFiles.length > 0) { - onRecentFilesSelected(selectedFiles); - onClose(); + // Remove active files that were unchecked + const uncheckedActiveIds = activeFileIds.filter((id) => !selectedFilesSet.has(id)); + if (uncheckedActiveIds.length > 0) { + removeFiles(uncheckedActiveIds, false); } - }, [selectedFiles, onRecentFilesSelected, onClose]); + + // Add newly checked files (not already active) + const newlySelected = selectedFiles.filter((f) => !activeFileIds.includes(f.id)); + if (newlySelected.length > 0) { + onRecentFilesSelected(newlySelected); + } + + onClose(); + }, [selectedFiles, selectedFilesSet, activeFileIds, removeFiles, onRecentFilesSelected, onClose]); const handleSearchChange = useCallback((value: string) => { setSearchTerm(value); @@ -586,11 +610,12 @@ export const FileManagerProvider: React.FC = ({ setSelectedFileIds([]); setLastClickedIndex(null); } else { - // Select all filtered files - setSelectedFileIds(filteredFiles.map((file) => file.id).filter(Boolean)); + // Select all filtered files, capped at maxSelectable + const allIds = filteredFiles.map((file) => file.id).filter(Boolean) as FileId[]; + setSelectedFileIds(maxSelectable != null ? allIds.slice(0, maxSelectable) : allIds); setLastClickedIndex(null); } - }, [filteredFiles, selectedFileIds]); + }, [filteredFiles, selectedFileIds, maxSelectable]); const handleDeleteSelected = useCallback(async () => { if (selectedFileIds.length === 0) return; diff --git a/frontend/src/core/contexts/FilesModalContext.tsx b/frontend/src/core/contexts/FilesModalContext.tsx index c0bc729977..ec9d42b5c7 100644 --- a/frontend/src/core/contexts/FilesModalContext.tsx +++ b/frontend/src/core/contexts/FilesModalContext.tsx @@ -20,8 +20,10 @@ interface FilesModalContextType { openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void; + maxSelectable?: number | null; }) => void; closeFilesModal: () => void; + maxSelectable: number | null; onFileUpload: (files: File[]) => void; onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void; onModalClose?: () => void; @@ -38,6 +40,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); const [insertAfterPage, setInsertAfterPage] = useState(); const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>(); + const [maxSelectable, setMaxSelectable] = useState(null); const importBundleToWorkbench = useCallback( async ( @@ -172,9 +175,14 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch }, []); const openFilesModal = useCallback( - (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => { + (options?: { + insertAfterPage?: number; + customHandler?: (files: File[], insertAfterPage?: number) => void; + maxSelectable?: number | null; + }) => { setInsertAfterPage(options?.insertAfterPage); setCustomHandler(() => options?.customHandler); + setMaxSelectable(options?.maxSelectable ?? null); setIsFilesModalOpen(true); }, [], @@ -336,6 +344,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch onRecentFileSelect: handleRecentFileSelect, onModalClose, setOnModalClose: setModalCloseCallback, + maxSelectable, }), [ isFilesModalOpen, @@ -345,6 +354,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch handleRecentFileSelect, onModalClose, setModalCloseCallback, + maxSelectable, ], ); diff --git a/frontend/src/core/hooks/tools/changeMetadata/useMetadataExtraction.ts b/frontend/src/core/hooks/tools/changeMetadata/useMetadataExtraction.ts index e0d6e78131..f3b9706e1e 100644 --- a/frontend/src/core/hooks/tools/changeMetadata/useMetadataExtraction.ts +++ b/frontend/src/core/hooks/tools/changeMetadata/useMetadataExtraction.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { extractPDFMetadata } from "@app/services/pdfMetadataService"; -import { useSelectedFiles } from "@app/contexts/file/fileHooks"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters"; interface MetadataExtractionParams { @@ -8,7 +8,7 @@ interface MetadataExtractionParams { } export const useMetadataExtraction = (params: MetadataExtractionParams) => { - const { selectedFiles } = useSelectedFiles(); + const selectedFiles = useViewScopedFiles(); const [isExtractingMetadata, setIsExtractingMetadata] = useState(false); const [hasExtractedMetadata, setHasExtractedMetadata] = useState(false); const previousFileCountRef = useRef(0); diff --git a/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts index ad16126ca2..674c2ac111 100644 --- a/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts +++ b/frontend/src/core/hooks/tools/getPdfInfo/useGetPdfInfoOperation.ts @@ -51,7 +51,7 @@ export const useGetPdfInfoOperation = (): GetPdfInfoOperationHook => { const executeOperation = useCallback( async (_params: GetPdfInfoParameters, selectedFiles: StirlingFile[]) => { if (selectedFiles.length === 0) { - setErrorMessage(t("noFileSelected", "No files selected")); + setErrorMessage(t("noFileSelected", "No file loaded")); return; } diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index e92dd874f1..ee3e7f2c7a 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -95,7 +95,7 @@ export const useToolOperation = (config: ToolOperationConfig): async (params: TParams, selectedFiles: StirlingFile[]): Promise => { // Validation if (selectedFiles.length === 0) { - actions.setError(t("noFileSelected", "No files selected")); + actions.setError(t("noFileSelected", "No file loaded")); return; } diff --git a/frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts b/frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts index 87f5fd93e6..070e854700 100644 --- a/frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts +++ b/frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts @@ -1,21 +1,13 @@ import { useMemo } from "react"; -import { useAllFiles, useSelectedFiles } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useNavigationState } from "@app/contexts/NavigationContext"; import { StirlingFile } from "@app/types/fileContext"; -/** - * Returns the effective file set for tool operations. - * - * - Viewer: scopes to the single file currently shown, unless ignoreViewerScope is true. - * - FileEditor: scopes to the selected subset (empty selection → empty → button disabled). - * - PageEditor / custom workbenches: returns all loaded files (selection tracks pages, not files). - */ export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] { const { activeFileIndex } = useViewer(); const { files: allFiles } = useAllFiles(); const { workbench } = useNavigationState(); - const { selectedFiles } = useSelectedFiles(); return useMemo(() => { if (workbench === "viewer" && !ignoreViewerScope) { @@ -23,10 +15,6 @@ export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] { return viewerFile ? [viewerFile] : allFiles; } - if (workbench === "fileEditor") { - return selectedFiles; - } - return allFiles; - }, [workbench, allFiles, activeFileIndex, selectedFiles, ignoreViewerScope]); + }, [workbench, allFiles, activeFileIndex, ignoreViewerScope]); } diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts index e43ffb3f97..9c62ccd389 100644 --- a/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts +++ b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts @@ -49,7 +49,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => { const executeOperation = useCallback( async (_params: ShowJSParameters, selectedFiles: StirlingFile[]) => { if (selectedFiles.length === 0) { - setErrorMessage(t("noFileSelected", "No files selected")); + setErrorMessage(t("noFileSelected", "No file loaded")); return; } diff --git a/frontend/src/core/hooks/tools/validateSignature/useValidateSignatureOperation.ts b/frontend/src/core/hooks/tools/validateSignature/useValidateSignatureOperation.ts index c20d762eca..6832e6d767 100644 --- a/frontend/src/core/hooks/tools/validateSignature/useValidateSignatureOperation.ts +++ b/frontend/src/core/hooks/tools/validateSignature/useValidateSignatureOperation.ts @@ -59,7 +59,7 @@ export const useValidateSignatureOperation = (): ValidateSignatureOperationHook const executeOperation = useCallback( async (params: ValidateSignatureParameters, selectedFiles: StirlingFile[]) => { if (selectedFiles.length === 0) { - setErrorMessage(t("noFileSelected", "No files selected")); + setErrorMessage(t("noFileSelected", "No file loaded")); return; } diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index 89ebd2218e..b0d0307087 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import CompareRoundedIcon from "@mui/icons-material/CompareRounded"; +import CloseIcon from "@mui/icons-material/Close"; 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"; @@ -15,14 +15,14 @@ import { useCompareOperation, CompareOperationHook } from "@app/hooks/tools/comp 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 { getDefaultWorkbench } from "@app/types/workbench"; -import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { truncateCenter } from "@app/utils/textUtils"; +import { FileSelectorPicker, FileSelectorResult } from "@app/components/shared/FileSelectorPicker"; +import "@app/components/tools/compare/compareView.css"; const CUSTOM_VIEW_ID = "compareWorkbenchView"; const CUSTOM_WORKBENCH_ID = "custom:compareWorkbenchView" as const; @@ -36,12 +36,9 @@ const Compare = (props: BaseToolProps) => { setCustomWorkbenchViewData, clearCustomWorkbenchViewData, } = useToolWorkflow(); - const { selectors, actions: fileActions } = useFileContext(); - const { state: fileState } = useFileState(); - const { openFilesModal } = useFilesModalContext(); const base = useBaseTool("compare", useCompareParameters, useCompareOperation, props, { - minFiles: 2, + minFiles: 0, ignoreViewerScope: true, }); @@ -51,6 +48,20 @@ const Compare = (props: BaseToolProps) => { const compareIcon = useMemo(() => , []); const [swapConfirmOpen, setSwapConfirmOpen] = useState(false); const [clearConfirmOpen, setClearConfirmOpen] = useState(false); + + // Slot state — files loaded directly for comparison, never added to workbench + const [baseSlot, setBaseSlot] = useState(null); + const [compSlot, setCompSlot] = useState(null); + + // Sync params fileIds from slots (needed for operation result matching) + useEffect(() => { + base.params.setParameters((prev) => ({ + ...prev, + baseFileId: baseSlot?.stirlingFile.fileId ?? null, + comparisonFileId: compSlot?.stirlingFile.fileId ?? null, + })); + }, [baseSlot?.stirlingFile.fileId, compSlot?.stirlingFile.fileId]); + const performClearSelected = useCallback(() => { try { base.operation.cancelOperation(); @@ -62,15 +73,11 @@ const Compare = (props: BaseToolProps) => { } 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"); - } + setBaseSlot(null); + setCompSlot(null); clearCustomWorkbenchViewData(CUSTOM_VIEW_ID); navigationActions.setWorkbench(getDefaultWorkbench()); - }, [base.operation, base.params, clearCustomWorkbenchViewData, fileActions, navigationActions]); + }, [base.operation, clearCustomWorkbenchViewData, navigationActions]); useEffect(() => { const handler = () => { @@ -86,7 +93,6 @@ const Compare = (props: BaseToolProps) => { 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, @@ -95,61 +101,9 @@ const Compare = (props: BaseToolProps) => { return () => { unregisterCustomWorkbenchView(CUSTOM_VIEW_ID); }; - // Register once; avoid re-registering on translation/prop changes which clears data mid-flight + // Register once only }, []); - // On mount: clear selections unless exactly 2 files are loaded. - useEffect(() => { - if ((fileState.files.ids as FileId[]).length !== 2) { - fileActions.clearSelections(); - } - }, []); - - // Track previous file count to detect the transition to exactly 2 files. - const prevAllIdsLengthRef = useRef(null); - - // Auto-fill slots when the file count first reaches 2; respect manual picker changes after that. - useEffect(() => { - const selectedIds = fileState.ui.selectedFileIds as FileId[]; - const allIds = fileState.files.ids as FileId[]; - const prevLength = prevAllIdsLengthRef.current; - prevAllIdsLengthRef.current = allIds.length; - - if (allIds.length === 2 && prevLength !== 2) { - // Transitioned to exactly 2 files — auto-fill both slots. - const [firstId, secondId] = allIds as [FileId, FileId]; - fileActions.setSelectedFiles([firstId, secondId]); - base.params.setParameters((prev) => { - if (prev.baseFileId === firstId && prev.comparisonFileId === secondId) return prev; - return { ...prev, baseFileId: firstId, comparisonFileId: secondId }; - }); - return; - } - - if (selectedIds.length > 2) { - fileActions.setSelectedFiles(selectedIds.slice(0, 2) as FileId[]); - return; - } - - const nextBase = (selectedIds[0] ?? null) as FileId | null; - const nextComp = (selectedIds[1] ?? null) as FileId | null; - base.params.setParameters((prev) => { - if (prev.baseFileId === nextBase && prev.comparisonFileId === nextComp) return prev; - return { ...prev, baseFileId: nextBase, comparisonFileId: nextComp }; - }); - }, [fileState.ui.selectedFileIds, fileState.files.ids]); - - // Clear a slot if its file is removed from the workbench. - useEffect(() => { - const allIds = fileState.files.ids as FileId[]; - if (params.baseFileId && !allIds.includes(params.baseFileId as FileId)) { - base.params.setParameters((prev) => ({ ...prev, baseFileId: null })); - } - if (params.comparisonFileId && !allIds.includes(params.comparisonFileId as FileId)) { - base.params.setParameters((prev) => ({ ...prev, comparisonFileId: null })); - } - }, [fileState.files.ids]); - // Track workbench data and drive loading/result state transitions const lastProcessedAtRef = useRef(null); const lastWorkbenchDataRef = useRef(null); @@ -175,33 +129,21 @@ const Compare = (props: BaseToolProps) => { ); 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; + (baseId: FileId | null, compId: FileId | null, baseFile: StirlingFile | null, comparisonFile: StirlingFile | null) => { + if (!baseId || !compId) return; updateWorkbenchData({ result: null, baseFileId: baseId, comparisonFileId: compId, - baseLocalFile: resolvedBaseFile, - comparisonLocalFile: resolvedComparisonFile, + baseLocalFile: baseFile, + comparisonLocalFile: comparisonFile, isLoading: true, }); lastProcessedAtRef.current = null; }, - [selectors, updateWorkbenchData], + [updateWorkbenchData], ); useEffect(() => { @@ -226,15 +168,12 @@ const Compare = (props: BaseToolProps) => { 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, + baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null, + comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null, isLoading: false, }); lastProcessedAtRef.current = processedAt; @@ -243,149 +182,87 @@ const Compare = (props: BaseToolProps) => { 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, + baseLocalFile: baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null, + comparisonLocalFile: compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null, isLoading: true, }); return; } }, [ base.operation.isLoading, + baseSlot, clearCustomWorkbenchViewData, + compSlot, 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); + if (!baseSlot || !compSlot) return; + const baseId = baseSlot.stirlingFile.fileId; + const compId = compSlot.stirlingFile.fileId; + const selected: StirlingFile[] = [baseSlot.stirlingFile, compSlot.stirlingFile]; - prepareWorkbenchForRun(baseId, compId, { baseFile: baseSel ?? null, comparisonFile: compSel ?? null }); - if (baseId && compId) { + prepareWorkbenchForRun(baseId, compId, baseSlot.stirlingFile, compSlot.stirlingFile); + requestAnimationFrame(() => { + navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); + }); + + await operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected); + }, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]); + + const performSwap = useCallback(() => { + if (!baseSlot || !compSlot) return; + const newBase = compSlot; + const newComp = baseSlot; + setBaseSlot(newBase); + setCompSlot(newComp); + if (operation.result) { + const baseId = newBase.stirlingFile.fileId; + const compId = newComp.stirlingFile.fileId; + const selected: StirlingFile[] = [newBase.stirlingFile, newComp.stirlingFile]; + prepareWorkbenchForRun(baseId, compId, newBase.stirlingFile, newComp.stirlingFile); requestAnimationFrame(() => { navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID); }); + void operation.executeOperation({ ...params, baseFileId: baseId, comparisonFileId: compId }, selected); } - - 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 + }, [baseSlot, compSlot, navigationActions, operation, params, prepareWorkbenchForRun]); const handleSwap = useCallback(() => { - const baseId = params.baseFileId as FileId | null; - const compId = params.comparisonFileId as FileId | null; - if (!baseId || !compId) return; + if (!baseSlot || !compSlot) return; if (operation.result) { setSwapConfirmOpen(true); return; } performSwap(); - }, [operation.result, params.baseFileId, params.comparisonFileId, performSwap]); + }, [baseSlot, compSlot, operation.result, performSwap]); - const renderSelectedFile = useCallback( + const clearSlot = useCallback((role: "base" | "comparison") => { + if (role === "base") setBaseSlot(null); + else setCompSlot(null); + }, []); + + const renderSlot = useCallback( (role: "base" | "comparison") => { - const fileId = role === "base" ? params.baseFileId : params.comparisonFileId; - const stub = fileId ? selectors.getStirlingFileStub(fileId) : undefined; + const slot = role === "base" ? baseSlot : compSlot; + const otherSlot = role === "base" ? compSlot : baseSlot; + const stub = slot?.stub; - // 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) { + 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; - if (!stub) { return ( - - openFilesModal({}) : undefined} - > - - {t( - role === "base" ? "compare.original.placeholder" : "compare.edited.placeholder", - role === "base" ? "Select the original PDF" : "Select the edited PDF", - )} - - {shouldShowAddButton && ( - { - e.stopPropagation(); - openFilesModal({}); - }} - style={{ flexShrink: 0 }} - > - - - )} - - - ); - } - // Build compact meta line for pages and date - const dateMs = (stub?.lastModified || stub?.createdAt) ?? null; - const dateText = dateMs - ? new Date(dateMs).toLocaleDateString(undefined, { month: "short", day: "2-digit", year: "numeric" }) - : ""; - const pageCount = stub?.processedFile?.totalPages || null; - - return ( - { background: "var(--bg-surface)", width: "100%", minHeight: "9rem", + position: "relative", }} > + clearSlot(role)} + aria-label={t("compare.clearSlot", "Remove file")} + > + + - - + + - - - {truncateCenter(stub?.name || "", 50)} + + + {truncateCenter(stub.name || "", 50)} {pageCount && dateText && ( - <> - - {pageCount} {t("compare.pages", "pages")} -
- {dateText} -
- + + {pageCount} {t("compare.pages", "pages")} +
+ {dateText} +
)}
-
+ ); + } + + const isDisabled = role === "comparison" && !baseSlot; + + return ( + { + if (role === "base") setBaseSlot(result); + else setCompSlot(result); + }} + /> ); }, - [params.baseFileId, params.comparisonFileId, selectors, t, openFilesModal], + [baseSlot, compSlot, clearSlot, t], ); - 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 && + baseSlot && + compSlot && + baseSlot.stirlingFile.fileId !== compSlot.stirlingFile.fileId && !base.operation.isLoading && base.endpointEnabled !== false, ); - const hasBothSelected = Boolean(params.baseFileId && params.comparisonFileId); - const hasAnyFiles = selectors.getFiles().length > 0; + const hasBothSelected = Boolean(baseSlot && compSlot); + const hasAnySelected = Boolean(baseSlot || compSlot); return createToolFlow({ files: { @@ -447,87 +349,50 @@ const Compare = (props: BaseToolProps) => { title: t("compare.selection.originalEditedTitle", "Select Original and Edited PDFs"), isVisible: true, content: ( - - {/* Header row: Original PDF + Clear selected aligned to swap column */} - - - {t("compare.original.label", "Original PDF")} - + +
- - - {renderSelectedFile("base")} -
- {/* Edited PDF section header */} - - {t("compare.edited.label", "Edited PDF")} - - {renderSelectedFile("comparison")} - - {hasBothSelected && ( - - - - )} + + {t("compare.swap.label", "Swap")} + + )} +
+ setSwapConfirmOpen(false)} @@ -558,6 +423,7 @@ const Compare = (props: BaseToolProps) => { + setClearConfirmOpen(false)} @@ -566,9 +432,7 @@ const Compare = (props: BaseToolProps) => { size="sm" > - - {t("compare.clear.confirmBody", "This will close the current comparison and take you back to Active Files.")} - + {t("compare.clear.confirmBody", "This will clear the current file selections.")} -
+ ), }, ], @@ -594,6 +458,8 @@ const Compare = (props: BaseToolProps) => { loadingText: t("compare.loading", "Comparing..."), onClick: handleExecuteCompare, disabled: !canExecute, + // Explicitly null so the noFiles gate is bypassed — Compare manages its own slot state + disabledReason: base.endpointEnabled === false ? "endpointUnavailable" : null, testId: "compare-execute", disableScopeHints: true, }, diff --git a/frontend/src/core/tools/Merge.tsx b/frontend/src/core/tools/Merge.tsx index a99d1bfe78..d4a93792d1 100644 --- a/frontend/src/core/tools/Merge.tsx +++ b/frontend/src/core/tools/Merge.tsx @@ -9,7 +9,7 @@ import { useMergeOperation } from "@app/hooks/tools/merge/useMergeOperation"; import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useMergeTips } from "@app/components/tooltips/useMergeTips"; -import { useFileManagement, useSelectedFiles, useAllFiles } from "@app/contexts/FileContext"; +import { useFileManagement, useAllFiles } from "@app/contexts/FileContext"; import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; const Merge = (props: BaseToolProps) => { @@ -17,8 +17,7 @@ const Merge = (props: BaseToolProps) => { const mergeTips = useMergeTips(); // File selection hooks for custom sorting - const { fileIds } = useAllFiles(); - const { selectedFileStubs } = useSelectedFiles(); + const { fileIds, fileStubs } = useAllFiles(); const { reorderFiles } = useFileManagement(); const base = useBaseTool("merge", useMergeParameters, useMergeOperation, props, { minFiles: 2, ignoreViewerScope: true }); @@ -89,7 +88,7 @@ const Merge = (props: BaseToolProps) => { // Custom file sorting logic for merge tool const sortFiles = useCallback( (sortType: "filename" | "dateModified", ascending: boolean = true) => { - const sortedStubs = [...selectedFileStubs].sort((stubA, stubB) => { + const sortedStubs = [...fileStubs].sort((stubA, stubB) => { let comparison = 0; switch (sortType) { case "filename": @@ -106,7 +105,7 @@ const Merge = (props: BaseToolProps) => { const deselectedIds = fileIds.filter((id) => !selectedIds.includes(id)); reorderFiles([...selectedIds, ...deselectedIds]); }, - [selectedFileStubs, fileIds, reorderFiles, naturalCompare], + [fileStubs, fileIds, reorderFiles, naturalCompare], ); return createToolFlow({