remove selected and de-selected logic and change the compare tool to accomadate (#6095)
# 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.
<img width="2056" height="1081" alt="Screenshot 2026-04-10 at 5 28
07 PM"
src="https://github.com/user-attachments/assets/e04f1c0e-91fc-4564-a6f1-b9b992ca4f70"
/>
3. Files on active files are cleaner looking, on hover shows buttons,
tool runs, versions
<img width="325" height="378" alt="Screenshot 2026-04-10 at 4 56 52 PM"
src="https://github.com/user-attachments/assets/d29d88fe-2c72-4c5f-9650-1376085bcff6"
/>
<img width="286" height="363" alt="Screenshot 2026-04-10 at 4 57 05 PM"
src="https://github.com/user-attachments/assets/387c1709-de22-4cc8-b658-9f4c9f192af1"
/>
# 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
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
dist/
|
||||
node_modules/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
public/vendor/
|
||||
public/pdfjs*/
|
||||
public/js/thirdParty/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -20,7 +20,7 @@ interface FileManagerProps {
|
||||
}
|
||||
|
||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
|
||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect, maxSelectable } = useFilesModalContext();
|
||||
const { config } = useAppConfig();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -196,6 +196,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
isOpen={isFilesModalOpen}
|
||||
maxSelectable={maxSelectable}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(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<FileId[]>([]);
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(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<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
const isSelected = selectedFiles.includes(file.id);
|
||||
|
||||
// ---- Meta formatting ----
|
||||
const prettySize = useMemo(() => {
|
||||
return formatFileSize(file.size);
|
||||
}, [file.size]);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(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<HoverAction[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -249,6 +210,25 @@ const FileEditorThumbnail = ({
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pin",
|
||||
icon: <PushPinIcon style={{ fontSize: 20 }} />,
|
||||
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: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
@@ -258,36 +238,32 @@ const FileEditorThumbnail = ({
|
||||
onDownloadFile(file.id);
|
||||
},
|
||||
},
|
||||
...(canUpload || canShare
|
||||
...(canUpload
|
||||
? [
|
||||
...(canUpload
|
||||
? [
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
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: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.share", "Share"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
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: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
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 (
|
||||
<div
|
||||
@@ -368,193 +343,117 @@ const FileEditorThumbnail = ({
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-tour="file-card-checkbox"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
className={`${styles.card} select-none`}
|
||||
style={{ opacity: isDragging ? 0.9 : 1 }}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onMouseEnter={() => setShowHoverMenu(true)}
|
||||
onMouseLeave={() => setShowHoverMenu(false)}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t("error._value", "Error")}</span>
|
||||
<div className={styles.thumbInner}>
|
||||
{/* Thumbnail area */}
|
||||
<div className={styles.thumbWrap}>
|
||||
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
|
||||
<div className={styles.thumbUnit}>
|
||||
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
|
||||
<div className={styles.toolChainBar}>
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth="100%"
|
||||
color="var(--mantine-color-gray-7)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
color="var(--checkbox-checked-bg)"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>{t("unsupported", "Unsupported")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{isEncrypted && (
|
||||
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
|
||||
<ActionIcon
|
||||
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
>
|
||||
<LockOpenIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip
|
||||
label={
|
||||
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={
|
||||
isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
data-tour="file-card-pin"
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
<div
|
||||
className={styles.thumbContainer}
|
||||
data-supported={isSupported}
|
||||
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Error overlay */}
|
||||
{hasError && (
|
||||
<div className={styles.errorOverlay}>
|
||||
<span className={styles.errorPill}>{t("error._value", "Error")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
textAlign: "center",
|
||||
background: "var(--file-card-bg)",
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"} • ${prettySize}`}>
|
||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{`v${file.versionNumber} - `}
|
||||
{dateLabel}
|
||||
{extUpper ? ` - ${extUpper} file` : ""}
|
||||
{pageLabel ? ` - ${pageLabel}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
{/* Thumbnail image or loading state */}
|
||||
{file.thumbnailUrl ? (
|
||||
<PrivateContent>
|
||||
<img
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
className={styles.thumbImage}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : file.type?.startsWith("application/pdf") ? (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* Preview area */}
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl ? (
|
||||
<PrivateContent>
|
||||
<img
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : file.type?.startsWith("application/pdf") ? (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
|
||||
{/* Tool chain display at bottom */}
|
||||
{file.toolHistory && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "4px",
|
||||
left: "4px",
|
||||
right: "4px",
|
||||
padding: "4px 6px",
|
||||
textAlign: "center",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth={"100%"}
|
||||
color="var(--mantine-color-gray-7)"
|
||||
/>
|
||||
{/* Badges — visible on hover via CSS */}
|
||||
<div className={styles.thumbBadges}>
|
||||
<span className={styles.versionBadgeThumb}>v{file.versionNumber}</span>
|
||||
{isPinned && (
|
||||
<span className={styles.pinnedBadge}>
|
||||
<PushPinIcon style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
)}
|
||||
{isSharedFile && !isOwnedOrLocal && (
|
||||
<span className={styles.ownershipBadge}>{t("fileManager.sharedWithYou", "Shared")}</span>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="yellow"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<LockOpenIcon style={{ fontSize: 12 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* end thumbUnit */}
|
||||
|
||||
{/* Drag handle */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
|
||||
<HoverActionMenu show={isMobile} actions={hoverActions} position="outside" visibility="cssHover" />
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
|
||||
{/* File name + meta */}
|
||||
<div className={styles.fileText}>
|
||||
<p className={styles.fileName}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</p>
|
||||
<p className={styles.fileMeta}>{metaLine}</p>
|
||||
</div>
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
@@ -569,7 +468,7 @@ const FileEditorThumbnail = ({
|
||||
<>
|
||||
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
@@ -587,7 +486,7 @@ const FileEditorThumbnail = ({
|
||||
<>
|
||||
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
@@ -601,6 +500,8 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Shared edit notice modal */}
|
||||
<Modal
|
||||
opened={showSharedEditNotice}
|
||||
onClose={() => setShowSharedEditNotice(false)}
|
||||
|
||||
@@ -5,58 +5,27 @@ import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface FileEditorRightRailButtonsParams {
|
||||
totalItems: number;
|
||||
selectedCount: number;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onCloseSelected: () => void;
|
||||
onCloseAll: () => void;
|
||||
}
|
||||
|
||||
export function useFileEditorRightRailButtons({
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onCloseSelected,
|
||||
}: FileEditorRightRailButtonsParams) {
|
||||
export function useFileEditorRightRailButtons({ totalItems, onCloseAll }: FileEditorRightRailButtonsParams) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: "file-select-all",
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.selectAll", "Select All"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
|
||||
section: "top" as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
visible: totalItems > 0,
|
||||
onClick: onSelectAll,
|
||||
},
|
||||
{
|
||||
id: "file-deselect-all",
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
|
||||
section: "top" as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onDeselectAll,
|
||||
},
|
||||
{
|
||||
id: "file-close-selected",
|
||||
id: "file-close-all",
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
|
||||
tooltip: t("rightRail.closeAll", "Close All Files"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.closeAll", "Close All Files") : "Close All Files",
|
||||
section: "top" as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
disabled: totalItems === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onCloseSelected,
|
||||
onClick: onCloseAll,
|
||||
},
|
||||
],
|
||||
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
|
||||
[t, i18n.language, totalItems, onCloseAll],
|
||||
);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
|
||||
@@ -86,7 +86,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
|
||||
<PrivateContent>{currentFile ? currentFile.name : "No file loaded"}</PrivateContent>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ""}
|
||||
|
||||
@@ -148,14 +148,8 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: isHistoryFile || isActive ? "default" : "pointer",
|
||||
backgroundColor: isActive
|
||||
? "var(--file-active-bg)"
|
||||
: isSelected
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: shouldShowHovered
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: "var(--bg-file-list)",
|
||||
cursor: isHistoryFile ? "default" : "pointer",
|
||||
backgroundColor: isSelected || shouldShowHovered ? "var(--mantine-color-gray-1)" : "var(--bg-file-list)",
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
transition: "background-color 0.15s ease",
|
||||
userSelect: "none",
|
||||
@@ -165,7 +159,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
|
||||
borderLeft: isHistoryFile ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history
|
||||
}}
|
||||
onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -175,16 +169,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
<Box>
|
||||
{/* Checkbox for regular files only */}
|
||||
<Checkbox
|
||||
checked={isActive || isSelected}
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
disabled={isActive}
|
||||
color={isActive ? "green" : undefined}
|
||||
styles={{
|
||||
input: {
|
||||
cursor: isActive ? "not-allowed" : "pointer",
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -162,7 +162,7 @@ const BulkShareModal: React.FC<BulkShareModalProps> = ({ opened, onClose, files,
|
||||
{t("storageShare.bulkDescription", "Create one link to share all selected files with signed-in users.")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("storageShare.fileCount", "{{count}} files selected", {
|
||||
{t("storageShare.fileCount", "{{count}} files", {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -89,7 +89,7 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({ opene
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("storageUpload.bulkDescription", "This uploads the selected files to your server storage.")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("storageUpload.fileCount", "{{count}} files selected", {
|
||||
{t("storageUpload.fileCount", "{{count}} files", {
|
||||
count: files.length,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -214,7 +214,7 @@ const FilePickerModal = ({ opened, onClose, storedFiles, onSelectFiles }: FilePi
|
||||
{/* Selection summary */}
|
||||
{selectedFileIds.length > 0 && (
|
||||
<Text size="sm" c="blue" ta="center">
|
||||
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
|
||||
{selectedFileIds.length} {t("fileManager.filesSelected", "files")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/* ── Trigger ─────────────────────────────────────── */
|
||||
.trigger {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-surface);
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.trigger:hover:not(.triggerDisabled) {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.triggerDisabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mantine-color-blue-5) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Popover dropdown ────────────────────────────── */
|
||||
.dropdown {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md) !important;
|
||||
min-width: 340px;
|
||||
}
|
||||
|
||||
/* ── Header area (tabs + sort) ───────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0.45rem;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.tabGroup {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* One slim row: Saved files | Workbench | Upload (text only, no icon) */
|
||||
.slimTabBar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
max-height: 26px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--mantine-color-default-hover) 85%, transparent);
|
||||
padding: 1px;
|
||||
gap: 1px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.slimTab,
|
||||
.slimTabActive {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 1px 0.35rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
background 0.1s ease,
|
||||
color 0.1s ease;
|
||||
}
|
||||
|
||||
.slimTab {
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.slimTab:hover {
|
||||
color: var(--mantine-color-text);
|
||||
background: color-mix(in srgb, var(--mantine-color-body) 60%, transparent);
|
||||
}
|
||||
|
||||
.slimTabActive {
|
||||
background: var(--mantine-color-blue-filled);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.slimTabUpload {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
padding: 1px 0.45rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
.slimTabUpload:hover:not(:disabled) {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
background: color-mix(in srgb, var(--mantine-color-blue-1) 35%, transparent);
|
||||
}
|
||||
|
||||
.slimTabUpload:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sort button group ───────────────────────────── */
|
||||
.sortGroup {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.sortBtn {
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
border-radius: calc(var(--radius-sm) - 2px);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.6;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sortBtn:hover {
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.sortBtnActive {
|
||||
background: var(--mantine-color-blue-filled);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sortArrow {
|
||||
opacity: 0.85;
|
||||
font-size: 0.65rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Scrollable file list ────────────────────────── */
|
||||
.list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Individual file row ─────────────────────────── */
|
||||
.fileItem {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-default);
|
||||
text-align: left;
|
||||
transition: background 0.08s ease;
|
||||
}
|
||||
|
||||
.fileItem:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.fileItem:hover:not(:disabled) {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.fileItem:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.fileItemContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Empty / loading states ──────────────────────── */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Box, Popover, ScrollArea, Text, Loader, FileButton } from "@mantine/core";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createStirlingFile, createFileId, createNewStirlingFileStub } from "@app/types/fileContext";
|
||||
import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useFileContext } from "@app/contexts/file/fileHooks";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { parseContentDispositionFilename, extractLatestFilesFromBundle } from "@app/services/shareBundleUtils";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
|
||||
import styles from "@app/components/shared/FileSelectorPicker.module.css";
|
||||
|
||||
const LS_TAB = "filePicker.tab";
|
||||
const LS_SORT = "filePicker.sort";
|
||||
const LS_SORT_DIR = "filePicker.sortDir";
|
||||
|
||||
function lsGet<const T extends readonly string[]>(key: string, fallback: T[number], valid: T): T[number] {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
const allowed = valid as readonly string[];
|
||||
if (v && allowed.includes(v)) return v as T[number];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function lsSet(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(ms: number | undefined): string {
|
||||
if (!ms) return "";
|
||||
return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function buildMeta(stub: StirlingFileStub): string {
|
||||
const parts: string[] = [];
|
||||
const pages = stub.processedFile?.totalPages;
|
||||
if (pages) parts.push(`${pages} ${pages === 1 ? "page" : "pages"}`);
|
||||
const size = formatBytes(stub.size ?? 0);
|
||||
if (size) parts.push(size);
|
||||
const date = formatDate(stub.lastModified || stub.createdAt);
|
||||
if (date) parts.push(date);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export interface FileSelectorResult {
|
||||
stub: StirlingFileStub;
|
||||
stirlingFile: StirlingFile;
|
||||
}
|
||||
|
||||
export interface FileSelectorPickerProps {
|
||||
placeholder?: string;
|
||||
/** FileIds to hide from both lists (e.g. the other slot's current selection) */
|
||||
excludeIds?: string[];
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Called with the stub (for display) and the ready-to-use StirlingFile (for processing).
|
||||
* Files are NOT added to the workbench — data is loaded inline.
|
||||
*/
|
||||
onSelect: (result: FileSelectorResult) => void;
|
||||
}
|
||||
|
||||
export function FileSelectorPicker({ placeholder, excludeIds = [], disabled = false, onSelect }: FileSelectorPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"workbench" | "saved">(() => lsGet(LS_TAB, "saved", ["workbench", "saved"]));
|
||||
const [sortBy, setSortBy] = useState<"date" | "name">(() => lsGet(LS_SORT, "date", ["date", "name"]));
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">(() => lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
|
||||
const [savedStubs, setSavedStubs] = useState<StirlingFileStub[]>([]);
|
||||
const [savedLoading, setSavedLoading] = useState(false);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
|
||||
const { fileStubs: workbenchStubs } = useAllFiles();
|
||||
const { selectors } = useFileContext();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
|
||||
const handleTabChange = useCallback((tab: "workbench" | "saved") => {
|
||||
lsSet(LS_TAB, tab);
|
||||
setActiveTab(tab);
|
||||
}, []);
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
(sort: "date" | "name") => {
|
||||
if (sort === sortBy) {
|
||||
const newDir = sortDir === "asc" ? "desc" : "asc";
|
||||
lsSet(LS_SORT_DIR, newDir);
|
||||
setSortDir(newDir);
|
||||
} else {
|
||||
const defaultDir: "asc" | "desc" = sort === "name" ? "asc" : "desc";
|
||||
lsSet(LS_SORT, sort);
|
||||
lsSet(LS_SORT_DIR, defaultDir);
|
||||
setSortBy(sort);
|
||||
setSortDir(defaultDir);
|
||||
}
|
||||
},
|
||||
[sortBy, sortDir],
|
||||
);
|
||||
|
||||
// Sync tab/sort from localStorage whenever this picker opens.
|
||||
// Both slot pickers mount simultaneously so their useState initialisers run at
|
||||
// the same time — a tab change in one picker writes to localStorage but the
|
||||
// other picker's React state is stale. Re-reading on open fixes that.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setActiveTab(lsGet(LS_TAB, "saved", ["workbench", "saved"]));
|
||||
setSortBy(lsGet(LS_SORT, "date", ["date", "name"]));
|
||||
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
|
||||
}, [isOpen]);
|
||||
|
||||
// Load saved files when the saved tab is active and the picker is open
|
||||
useEffect(() => {
|
||||
if (activeTab !== "saved" || !isOpen) return;
|
||||
setSavedLoading(true);
|
||||
loadRecentFiles()
|
||||
.then(setSavedStubs)
|
||||
.finally(() => setSavedLoading(false));
|
||||
}, [activeTab, isOpen, loadRecentFiles]);
|
||||
|
||||
const workbenchIdSet = useMemo(() => new Set(workbenchStubs.map((s) => s.id)), [workbenchStubs]);
|
||||
|
||||
const displayStubs = useMemo(() => {
|
||||
const base = activeTab === "workbench" ? workbenchStubs : savedStubs;
|
||||
const filtered = base.filter((s) => !excludeIds.includes(s.id));
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
return [...filtered].sort((a, b) =>
|
||||
sortBy === "name"
|
||||
? dir * a.name.localeCompare(b.name)
|
||||
: dir * ((a.lastModified || a.createdAt || 0) - (b.lastModified || b.createdAt || 0)),
|
||||
);
|
||||
}, [activeTab, workbenchStubs, savedStubs, excludeIds, sortBy, sortDir]);
|
||||
|
||||
const loadAndSelect = useCallback(
|
||||
async (stub: StirlingFileStub) => {
|
||||
if (loadingId) return;
|
||||
|
||||
// Workbench file — get StirlingFile directly from FileContext (no loading needed)
|
||||
if (workbenchIdSet.has(stub.id)) {
|
||||
const sf = selectors.getFile(stub.id as FileId);
|
||||
if (sf) {
|
||||
// Prefer the workbench stub (has thumbnail) over the saved stub (may not)
|
||||
const workbenchStub = selectors.getStirlingFileStub(stub.id as FileId) ?? stub;
|
||||
onSelect({ stub: workbenchStub, stirlingFile: sf });
|
||||
setIsOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Saved file — load bytes without touching the workbench
|
||||
setLoadingId(stub.id);
|
||||
try {
|
||||
let stirlingFile: StirlingFile | null = null;
|
||||
|
||||
if (stub.remoteShareToken) {
|
||||
const res = await apiClient.get(`/api/v1/storage/share-links/${stub.remoteShareToken}`, {
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any);
|
||||
const ct = res.headers?.["content-type"] || res.headers?.["Content-Type"] || "";
|
||||
const disp = res.headers?.["content-disposition"] || res.headers?.["Content-Disposition"] || "";
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || "shared-file",
|
||||
ct,
|
||||
);
|
||||
if (files[0]) stirlingFile = createStirlingFile(files[0], createFileId());
|
||||
} else if (stub.remoteStorageId) {
|
||||
const res = await apiClient.get(`/api/v1/storage/files/${stub.remoteStorageId}/download`, {
|
||||
responseType: "blob",
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
} as any);
|
||||
const ct = res.headers?.["content-type"] || res.headers?.["Content-Type"] || "";
|
||||
const disp = res.headers?.["content-disposition"] || res.headers?.["Content-Disposition"] || "";
|
||||
const files = await extractLatestFilesFromBundle(
|
||||
res.data as Blob,
|
||||
parseContentDispositionFilename(disp) || stub.name,
|
||||
ct,
|
||||
);
|
||||
if (files[0]) stirlingFile = createStirlingFile(files[0], stub.id as FileId);
|
||||
} else {
|
||||
// Local IndexedDB file
|
||||
const localFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (localFile) stirlingFile = localFile;
|
||||
}
|
||||
|
||||
if (stirlingFile) {
|
||||
// Generate thumbnail on-the-fly if the stub doesn't already have one
|
||||
let resolvedStub = stub;
|
||||
if (!resolvedStub.thumbnailUrl) {
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(stirlingFile);
|
||||
if (thumbnail) {
|
||||
resolvedStub = { ...stub, thumbnailUrl: thumbnail };
|
||||
// Persist so subsequent opens don't regenerate
|
||||
void fileStorage.updateThumbnail(stirlingFile.fileId as FileId, thumbnail);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — thumbnail simply won't show
|
||||
}
|
||||
}
|
||||
onSelect({ stub: resolvedStub, stirlingFile });
|
||||
setIsOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("FileSelectorPicker: failed to load file", err);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[loadingId, workbenchIdSet, selectors, onSelect],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file || uploadBusy || disabled) return;
|
||||
setUploadBusy(true);
|
||||
try {
|
||||
const id = createFileId();
|
||||
let stub = createNewStirlingFileStub(file, id);
|
||||
const stirlingFile = createStirlingFile(file, id);
|
||||
// Generate a first-page thumbnail for the uploaded file
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
if (thumbnail) stub = { ...stub, thumbnailUrl: thumbnail };
|
||||
} catch {
|
||||
// Non-fatal — thumbnail simply won't show
|
||||
}
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
lsSet(LS_TAB, "saved");
|
||||
setActiveTab("saved");
|
||||
const refreshed = await loadRecentFiles();
|
||||
setSavedStubs(refreshed);
|
||||
onSelect({ stub, stirlingFile });
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
console.error("FileSelectorPicker: upload failed", err);
|
||||
} finally {
|
||||
setUploadBusy(false);
|
||||
}
|
||||
},
|
||||
[disabled, loadRecentFiles, onSelect, uploadBusy],
|
||||
);
|
||||
|
||||
const triggerClass = [styles.trigger, disabled ? styles.triggerDisabled : "", isOpen ? styles.triggerOpen : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={isOpen}
|
||||
onChange={setIsOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
position="bottom-start"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
closeOnClickOutside
|
||||
clickOutsideEvents={["mousedown", "touchstart"]}
|
||||
>
|
||||
<Popover.Target>
|
||||
<Box
|
||||
className={triggerClass}
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => {
|
||||
if (!disabled) setIsOpen((o) => !o);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onKeyDown={(e) => {
|
||||
if (!disabled && (e.key === "Enter" || e.key === " ")) setIsOpen((o) => !o);
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Text size="sm" c="dimmed" style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{placeholder || t("fileSelectorPicker.placeholder", "Select file")}
|
||||
</Text>
|
||||
<AddIcon style={{ fontSize: 18, color: "var(--mantine-color-dimmed)", flexShrink: 0 }} />
|
||||
</Box>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown className={styles.dropdown}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.tabGroup}>
|
||||
<div
|
||||
className={styles.slimTabBar}
|
||||
role="group"
|
||||
aria-label={t("fileSelectorPicker.tabListLabel", "Saved files, workbench, upload")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "saved"}
|
||||
className={activeTab === "saved" ? styles.slimTabActive : styles.slimTab}
|
||||
onClick={() => handleTabChange("saved")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.saved", "Saved files")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeTab === "workbench"}
|
||||
className={activeTab === "workbench" ? styles.slimTabActive : styles.slimTab}
|
||||
onClick={() => handleTabChange("workbench")}
|
||||
>
|
||||
{t("fileSelectorPicker.tabs.workbench", "Workbench")}
|
||||
</button>
|
||||
<FileButton onChange={handleUpload} accept=".pdf,application/pdf" disabled={disabled || uploadBusy}>
|
||||
{(fbProps) => (
|
||||
<button
|
||||
{...fbProps}
|
||||
type="button"
|
||||
className={styles.slimTabUpload}
|
||||
aria-label={t("fileSelectorPicker.upload", "Upload from computer")}
|
||||
title={t("fileSelectorPicker.upload", "Upload from computer")}
|
||||
disabled={disabled || uploadBusy}
|
||||
>
|
||||
{uploadBusy ? <Loader size={11} /> : t("fileSelectorPicker.tabs.upload", "Upload")}
|
||||
</button>
|
||||
)}
|
||||
</FileButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.sortGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.sortBtn, sortBy === "date" ? styles.sortBtnActive : ""].join(" ")}
|
||||
onClick={() => handleSortChange("date")}
|
||||
title={
|
||||
sortDir === "desc"
|
||||
? t("fileSelectorPicker.sort.dateDesc", "Newest first")
|
||||
: t("fileSelectorPicker.sort.dateAsc", "Oldest first")
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.dateLabel", "Latest")}
|
||||
{sortBy === "date" && <span className={styles.sortArrow}>{sortDir === "desc" ? " ↓" : " ↑"}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.sortBtn, sortBy === "name" ? styles.sortBtnActive : ""].join(" ")}
|
||||
onClick={() => handleSortChange("name")}
|
||||
title={
|
||||
sortDir === "asc"
|
||||
? t("fileSelectorPicker.sort.nameAsc", "A to Z")
|
||||
: t("fileSelectorPicker.sort.nameDesc", "Z to A")
|
||||
}
|
||||
>
|
||||
{t("fileSelectorPicker.sort.nameLabel", "A–Z")}
|
||||
{sortBy === "name" && <span className={styles.sortArrow}>{sortDir === "asc" ? " ↑" : " ↓"}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea h={260} className={styles.list}>
|
||||
{savedLoading ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
) : displayStubs.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Text size="sm">{t("fileSelectorPicker.empty", "No files available")}</Text>
|
||||
</div>
|
||||
) : (
|
||||
displayStubs.map((stub) => {
|
||||
const meta = buildMeta(stub);
|
||||
const isItemLoading = loadingId === stub.id;
|
||||
return (
|
||||
<button
|
||||
key={stub.id}
|
||||
type="button"
|
||||
className={styles.fileItem}
|
||||
onClick={() => void loadAndSelect(stub)}
|
||||
disabled={!!loadingId}
|
||||
>
|
||||
<div className={styles.fileItemContent}>
|
||||
<span className={styles.fileName} title={stub.name}>
|
||||
{truncateCenter(stub.name, 48)}
|
||||
</span>
|
||||
{meta && <span className={styles.fileMeta}>{meta}</span>}
|
||||
</div>
|
||||
{isItemLoading && <Loader size="xs" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -18,19 +18,39 @@ interface HoverActionMenuProps {
|
||||
actions: HoverAction[];
|
||||
position?: "inside" | "outside";
|
||||
className?: string;
|
||||
visibility?: "state" | "cssHover";
|
||||
}
|
||||
|
||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ show, actions, position = "inside", className = "" }) => {
|
||||
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={`${styles.hoverMenu} ${position === "outside" ? styles.outside : styles.inside} ${className}`}
|
||||
style={{ opacity: show ? 1 : 0, zIndex: Z_INDEX_HOVER_ACTION_MENU }}
|
||||
style={style}
|
||||
data-hover-action-menu="true"
|
||||
data-hover-action-menu-mode={visibility}
|
||||
data-force-visible={show ? "true" : "false"}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseUp={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -26,11 +26,11 @@ const MultiSelectControls = ({
|
||||
<Box mb="md" p="md" style={{ backgroundColor: "var(--mantine-color-blue-0)", borderRadius: 8 }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{selectedCount} {t("fileManager.filesSelected", "files selected")}
|
||||
{selectedCount} {t("fileManager.filesSelected", "files")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button size="xs" variant="light" onClick={onClearSelection}>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
{t("fileManager.clearSelection", "Clear files")}
|
||||
</Button>
|
||||
|
||||
{onAddToUpload && (
|
||||
|
||||
@@ -39,9 +39,9 @@ const CreateSessionPanel = ({
|
||||
) : (
|
||||
<>
|
||||
<div className="quick-access-popout__section">
|
||||
<div className="quick-access-popout__label">{t("quickAccess.selectedFile", "Selected file")}</div>
|
||||
<div className="quick-access-popout__label">{t("quickAccess.selectedFile", "File")}</div>
|
||||
<div className="quick-access-popout__row-title">
|
||||
{selectedFiles[0]?.name || t("quickAccess.noFile", "No file selected")}
|
||||
{selectedFiles[0]?.name || t("quickAccess.noFile", "No file loaded")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
{title}
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text fw={600} size="lg" style={{ minWidth: 0, flex: 1 }} title={title || undefined}>
|
||||
{title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title}
|
||||
</Text>
|
||||
<Group justify="flex-end" align="center" gap="sm" wrap="nowrap">
|
||||
{(changes.length > 0 || Boolean(dropdownPlaceholder)) && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
@@ -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
|
||||
<Text size="sm" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }}>
|
||||
✓{" "}
|
||||
{selectedFiles.length === 1 ? (
|
||||
<PrivateContent>{t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name })}</PrivateContent>
|
||||
<PrivateContent>{t("fileSelected", "{{filename}}", { filename: selectedFiles[0]?.name })}</PrivateContent>
|
||||
) : (
|
||||
t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })
|
||||
t("filesSelected", "{{count}} files", { count: selectedFiles.length })
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<OperationButton {...props} submitText={scopedText} />
|
||||
@@ -57,11 +53,6 @@ export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...pro
|
||||
{t("tool.singleFileScope", "Only applying to: {{fileName}}", { fileName: viewerFileName })}
|
||||
</Text>
|
||||
)}
|
||||
{showSelectFilesHint && (
|
||||
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||
{t("tool.selectFilesHint", "Select files in Active Files to run this tool")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function NonPdfViewerWrapper(props: ViewerProps) {
|
||||
return (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Text c="dimmed" size="sm">
|
||||
No file selected
|
||||
No file loaded
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
isLoading: boolean;
|
||||
activeFileIds: FileId[];
|
||||
maxSelectable?: number | null;
|
||||
}
|
||||
|
||||
type RemoteDeleteChoice = "local" | "server" | "both" | "leave" | "cancel";
|
||||
@@ -95,10 +96,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
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<FileId[]>([]);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>(() => activeFileIds);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
|
||||
@@ -108,6 +110,14 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
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<Set<string>>(new Set());
|
||||
@@ -234,6 +244,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
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<FileManagerProviderProps> = ({
|
||||
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<FileManagerProviderProps> = ({
|
||||
);
|
||||
|
||||
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<FileManagerProviderProps> = ({
|
||||
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;
|
||||
|
||||
@@ -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<number | undefined>();
|
||||
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
|
||||
const [maxSelectable, setMaxSelectable] = useState<number | null>(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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export const useToolOperation = <TParams>(config: ToolOperationConfig<TParams>):
|
||||
async (params: TParams, selectedFiles: StirlingFile[]): Promise<void> => {
|
||||
// Validation
|
||||
if (selectedFiles.length === 0) {
|
||||
actions.setError(t("noFileSelected", "No files selected"));
|
||||
actions.setError(t("noFileSelected", "No file loaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+161
-295
@@ -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(() => <CompareRoundedIcon fontSize="small" />, []);
|
||||
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<FileSelectorResult | null>(null);
|
||||
const [compSlot, setCompSlot] = useState<FileSelectorResult | null>(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<number | null>(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<number | null>(null);
|
||||
const lastWorkbenchDataRef = useRef<CompareWorkbenchData | null>(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 (
|
||||
<Stack gap={6}>
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--border-default)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
padding: "0.75rem 1rem",
|
||||
background: "var(--bg-surface)",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
cursor: shouldShowAddButton ? "pointer" : "default",
|
||||
}}
|
||||
onClick={shouldShowAddButton ? () => openFilesModal({}) : undefined}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
role === "base" ? "compare.original.placeholder" : "compare.edited.placeholder",
|
||||
role === "base" ? "Select the original PDF" : "Select the edited PDF",
|
||||
)}
|
||||
</Text>
|
||||
{shouldShowAddButton && (
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal({});
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
// Build compact meta line for pages and date
|
||||
const dateMs = (stub?.lastModified || stub?.createdAt) ?? null;
|
||||
const dateText = dateMs
|
||||
? new Date(dateMs).toLocaleDateString(undefined, { month: "short", day: "2-digit", year: "numeric" })
|
||||
: "";
|
||||
const pageCount = stub?.processedFile?.totalPages || null;
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--border-default)",
|
||||
@@ -394,48 +271,73 @@ const Compare = (props: BaseToolProps) => {
|
||||
background: "var(--bg-surface)",
|
||||
width: "100%",
|
||||
minHeight: "9rem",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
onClick={() => clearSlot(role)}
|
||||
aria-label={t("compare.clearSlot", "Remove file")}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md">
|
||||
<Box className="compare-tool__thumbnail" style={{ alignSelf: "center" }}>
|
||||
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
|
||||
<Box style={{ alignSelf: "center" }}>
|
||||
<DocumentThumbnail file={stub} thumbnail={stub.thumbnailUrl || null} />
|
||||
</Box>
|
||||
<Stack className="compare-tool__details" style={{ minWidth: 0, overflow: "hidden", flex: 1 }}>
|
||||
<Text fw={600} title={stub?.name}>
|
||||
{truncateCenter(stub?.name || "", 50)}
|
||||
<Stack style={{ minWidth: 0, overflow: "hidden", flex: 1 }}>
|
||||
<Text fw={600} title={stub.name}>
|
||||
{truncateCenter(stub.name || "", 50)}
|
||||
</Text>
|
||||
{pageCount && dateText && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{pageCount} {t("compare.pages", "pages")}
|
||||
<br />
|
||||
{dateText}
|
||||
</Text>
|
||||
</>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{pageCount} {t("compare.pages", "pages")}
|
||||
<br />
|
||||
{dateText}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const isDisabled = role === "comparison" && !baseSlot;
|
||||
|
||||
return (
|
||||
<FileSelectorPicker
|
||||
placeholder={
|
||||
isDisabled
|
||||
? t("compare.edited.selectBaseFirst", "Select original PDF first")
|
||||
: t(
|
||||
role === "base" ? "compare.original.placeholder" : "compare.edited.placeholder",
|
||||
role === "base" ? "Select the original PDF" : "Select the edited PDF",
|
||||
)
|
||||
}
|
||||
excludeIds={otherSlot ? [otherSlot.stirlingFile.fileId as string] : []}
|
||||
disabled={isDisabled}
|
||||
onSelect={(result: FileSelectorResult) => {
|
||||
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: (
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: hasBothSelected ? "1fr 2.25rem" : "1fr",
|
||||
gap: "1rem",
|
||||
alignItems: "stretch",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Header row: Original PDF + Clear selected aligned to swap column */}
|
||||
<Box
|
||||
style={{
|
||||
gridColumn: hasBothSelected ? "1 / span 2" : "1",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="sm">
|
||||
{t("compare.original.label", "Original PDF")}
|
||||
</Text>
|
||||
<Stack gap="sm" className="compare-step-selection">
|
||||
<div className="compare-step-selection__clear-row">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => setClearConfirmOpen(true)}
|
||||
disabled={!hasAnyFiles}
|
||||
disabled={!hasAnySelected}
|
||||
styles={{ root: { textDecoration: "underline" } }}
|
||||
style={{
|
||||
background: !hasAnyFiles ? "transparent" : undefined,
|
||||
color: !hasAnyFiles ? "var(--spdf-clear-disabled-text)" : undefined,
|
||||
background: !hasAnySelected ? "transparent" : undefined,
|
||||
color: !hasAnySelected ? "var(--spdf-clear-disabled-text)" : undefined,
|
||||
}}
|
||||
>
|
||||
{t("compare.clearSelected", "Clear selected")}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
gridColumn: "1",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{renderSelectedFile("base")}
|
||||
<div style={{ height: "0.75rem" }} />
|
||||
{/* Edited PDF section header */}
|
||||
<Text fw={700} size="sm" style={{ marginBottom: "1rem", marginTop: "0.5rem" }}>
|
||||
{t("compare.edited.label", "Edited PDF")}
|
||||
</Text>
|
||||
{renderSelectedFile("comparison")}
|
||||
</Box>
|
||||
{hasBothSelected && (
|
||||
<Box
|
||||
style={{
|
||||
gridColumn: "2",
|
||||
gridRow: "2",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
</div>
|
||||
|
||||
<Text fw={700} size="sm" style={{ margin: 0 }}>
|
||||
{t("compare.original.label", "Original PDF")}
|
||||
</Text>
|
||||
|
||||
<div className="compare-step-selection__thumbs-row">
|
||||
<Stack gap="sm" className="compare-step-selection__thumbs-col">
|
||||
{renderSlot("base")}
|
||||
<Text fw={700} size="sm" style={{ margin: 0 }}>
|
||||
{t("compare.edited.label", "Edited PDF")}
|
||||
</Text>
|
||||
{renderSlot("comparison")}
|
||||
</Stack>
|
||||
|
||||
{hasBothSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className="compare-step-selection__swap"
|
||||
onClick={handleSwap}
|
||||
disabled={!hasBothSelected || base.operation.isLoading}
|
||||
style={{
|
||||
width: "2.25rem",
|
||||
height: "100%",
|
||||
padding: 0,
|
||||
borderRadius: "0.5rem",
|
||||
background: "var(--bg-surface)",
|
||||
border: "1px solid var(--border-default)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
disabled={base.operation.isLoading}
|
||||
aria-label={t("compare.swap.label", "Swap")}
|
||||
>
|
||||
<SwapVertRoundedIcon fontSize="medium" />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<SwapVertRoundedIcon className="compare-step-selection__swap-icon" fontSize="inherit" />
|
||||
<span className="compare-step-selection__swap-label">{t("compare.swap.label", "Swap")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
opened={swapConfirmOpen}
|
||||
onClose={() => setSwapConfirmOpen(false)}
|
||||
@@ -558,6 +423,7 @@ const Compare = (props: BaseToolProps) => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={clearConfirmOpen}
|
||||
onClose={() => setClearConfirmOpen(false)}
|
||||
@@ -566,9 +432,7 @@ const Compare = (props: BaseToolProps) => {
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t("compare.clear.confirmBody", "This will close the current comparison and take you back to Active Files.")}
|
||||
</Text>
|
||||
<Text>{t("compare.clear.confirmBody", "This will clear the current file selections.")}</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={() => setClearConfirmOpen(false)}>
|
||||
{t("cancel", "Cancel")}
|
||||
@@ -580,12 +444,12 @@ const Compare = (props: BaseToolProps) => {
|
||||
performClearSelected();
|
||||
}}
|
||||
>
|
||||
{t("compare.clear.confirm", "Clear and return")}
|
||||
{t("compare.clear.confirm", "Clear")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user