From 917edc43b301d03745931c42723ac1faf1e95d80 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:04:38 +0100 Subject: [PATCH] Add specific View Scope For Selected Files (#6050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fix 1 — Viewer bug (8 tools) 8 tools called `useFileSelection()` directly instead of routing through `useBaseTool`. In the viewer, this meant they operated on **all selected files** instead of only the one being viewed. For example: 10 files loaded, viewing file 3, running Add Stamp — all 10 files got stamped. **Root cause:** These tools had no view-scope awareness. `useFileSelection()` returns the raw workbench selection with no knowledge of which file is active in the viewer. **Fix:** A new hook `useViewScopedFiles` was introduced: ```ts // Viewer → only the active file // Everywhere else → all loaded files const selectedFiles = useViewScopedFiles(); ``` The 8 tools were updated to call this instead of `useFileSelection()`. **Tools fixed:** Add Stamp, Add Watermark, Add Password, Add Page Numbers, Add Attachments, Reorganize Pages, OCR, Convert --- ## Fix 2 — Page selector / active files context (all tools) `useBaseTool` returned `selectedFiles` (checked files only) in non-viewer contexts. In the page selector this is typically empty or stale — not the full set of loaded files that tools should operate on. **Fix:** `useBaseTool` was updated to use `useViewScopedFiles`, which returns all loaded files in non-viewer contexts. This affected every tool via `useBaseTool`. --- ## Workarounds for Compare & Merge Two tools intentionally need all loaded files regardless of view, so they use `ignoreViewerScope: true` in `useBaseTool`. **Compare** — needs exactly 2 files for its Original/Edited slots. Scoping to one file would break the comparison entirely. `ignoreViewerScope: true` is set and `disableScopeHints: true` hides the "(this file)" button label hint. The slot auto-mapping logic was also improved alongside this fix. **Merge** — needs 2+ files; merging a single file is meaningless. Rather than leaving the button silently disabled, Merge now: - Auto-redirects to the active files view on first open from the viewer - If the user navigates back to the viewer, shows a disabled button with a hint and a "Go to active files view" shortcut button --- ## How to Test --- ## Fix 1 — 8 tools (viewer scoping) ### Test steps (same for each) 1. Load 3 PDFs into workbench 2. Open viewer, navigate to file 2 3. Open the tool, configure settings, run 4. ✅ Only file 2 is in the results 5. ✅ Button label shows **"[Action] (this file)"** 6. ✅ A note below the button reads **"Only applying to: [filename]"** | Tool | What to configure | |---|---| | **Add Stamp** | Enter any text stamp or upload an image stamp | | **Add Watermark** | Select text watermark, enter any text | | **Add Page Numbers** | Leave defaults | | **Add Password** | Enter any owner + user password | | **Add Attachments** | Attach any small file | | **Reorganize Pages** | Enter a page range e.g. `1,2` | | **OCR** | Leave default language | | **Convert** | Convert PDF → any format | --- ## Fix 2 — All tools (page selector context) ### Test steps 1. Load 3 PDFs into workbench 2. Open the page selector view 3. Open any tool from the sidebar, run it 4. ✅ All 3 files are processed (not zero or a stale subset) --- ## Compare (intentionally ignores view scope) **A — Auto-fill with exactly 2 files** 1. Load exactly 2 PDFs 2. Open Compare from either the viewer or active files view 3. ✅ Both slots are filled automatically (Original + Edited) 4. ✅ No scope hint appears on the button **B — Manual selection with 3+ files** 1. Load 3+ PDFs 2. Open Compare 3. ✅ The first 2 files fill the slots 4. ✅ A 3rd file does not add a 3rd slot (capped at 2) **C — File removed mid-session** 1. Load 2 PDFs, let Compare auto-fill both slots 2. Remove one file from the workbench 3. ✅ The corresponding slot clears; the other slot is unchanged **D — Viewer mode** 1. Load 2 PDFs, open viewer 2. Open Compare from the viewer sidebar 3. ✅ Both files are still available for slot selection (not scoped to current file) --- ## Merge (intentionally ignores view scope, disabled in viewer) **A — Auto-redirect on first open from viewer** 1. Load 2+ PDFs, open the viewer 2. Open Merge from the viewer sidebar 3. ✅ Immediately redirected to the active files view **B — Viewer mode disabled state (after navigating back)** 1. From the active files view, open Merge, then navigate back to the viewer 2. ✅ Execute button is **disabled** with tooltip "Switch to the file editor to select multiple files" 3. ✅ A note appears: *"Merge needs 2 or more files. Head to the file editor to select them."* 4. ✅ A **"Go to active files view"** button is shown; clicking it navigates back **C — Active files view works normally** 1. Load 3 PDFs, open Merge from the active files view 2. ✅ All 3 files appear in the merge list 3. ✅ Button shows **"Merge (3 files)"** 4. Run the merge 5. ✅ Output is a single PDF containing all 3 files --- ## Button label behaviour (all tools) | Context | Expected button text | |---|---| | Viewer, 1 file loaded | `[Action]` (no suffix) | | Viewer, 2+ files loaded | `[Action] (this file)` | | Active files view, 1 file loaded | `[Action]` (no suffix) | | Active files view, 2+ files loaded | `[Action] (N files)` | | Merge in viewer | disabled — no suffix | | Compare | never shows scope suffix (`disableScopeHints: true`) | --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../public/locales/en-GB/translation.toml | 7 ++ .../core/components/fileEditor/FileEditor.tsx | 9 +- .../fileEditor/FileEditorThumbnail.tsx | 6 +- .../components/shared/FileDropdownMenu.tsx | 11 +-- .../tools/shared/OperationButton.tsx | 1 + .../tools/shared/ScopedOperationButton.tsx | 66 +++++++++++++++ .../tools/shared/createToolFlow.tsx | 33 +++++--- .../core/components/viewer/EmbedPdfViewer.tsx | 78 +++++++++-------- .../src/core/contexts/NavigationContext.tsx | 8 +- .../src/core/contexts/ToolWorkflowContext.tsx | 6 +- frontend/src/core/contexts/ViewerContext.tsx | 9 +- .../overlayPdfs/useOverlayPdfsParameters.ts | 15 +++- .../hooks/tools/shared/toolOperationTypes.ts | 1 + .../core/hooks/tools/shared/useBaseTool.ts | 43 ++++++---- .../hooks/tools/shared/useToolOperation.ts | 8 +- .../hooks/tools/shared/useViewScopedFiles.ts | 32 +++++++ frontend/src/core/tools/AddAttachments.tsx | 4 +- frontend/src/core/tools/AddPageNumbers.tsx | 4 +- frontend/src/core/tools/AddPassword.tsx | 4 +- frontend/src/core/tools/AddStamp.tsx | 4 +- frontend/src/core/tools/AddWatermark.tsx | 4 +- frontend/src/core/tools/Compare.tsx | 83 ++++++++++++------- frontend/src/core/tools/Convert.tsx | 5 +- frontend/src/core/tools/Merge.tsx | 33 +++++++- frontend/src/core/tools/OCR.tsx | 4 +- frontend/src/core/tools/ReorganizePages.tsx | 4 +- frontend/src/core/utils/textUtils.ts | 13 +++ 27 files changed, 347 insertions(+), 148 deletions(-) create mode 100644 frontend/src/core/components/tools/shared/ScopedOperationButton.tsx create mode 100644 frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index cf99c8aee..5a8565d5a 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -4422,6 +4422,8 @@ 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." +goToFileEditor = "Go to file editor" [merge.error] failed = "An error occurred while merging the PDFs." @@ -7523,6 +7525,11 @@ 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." +singleFileScope = "Only applying to: {{fileName}}" +scopeThisFile = "this file" +scopeFiles = "files" +selectFilesHint = "Select files in Active Files to run this tool" [tools] noSearchResults = "No tools found" diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index eac0cca7f..c230495e1 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -52,8 +52,8 @@ const FileEditor = ({ // Get navigation actions const { actions: navActions } = useNavigationActions(); - // Get viewer context for setting active file index - const { setActiveFileIndex } = useViewer(); + // Get viewer context for setting active file index and ID + const { setActiveFileIndex, setActiveFileId } = useViewer(); // Get file selection context const { setSelectedFiles } = useFileSelection(); @@ -351,12 +351,11 @@ const FileEditor = ({ const handleViewFile = useCallback((fileId: FileId) => { const index = activeStirlingFileStubs.findIndex(r => r.id === fileId); if (index !== -1) { - // Set the file as selected in context, sync the viewer index, and switch to viewer - setSelectedFiles([fileId]); + setActiveFileId(fileId as string); setActiveFileIndex(index); navActions.setWorkbench('viewer'); } - }, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions.setWorkbench]); + }, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index 2af8a87ab..a2973bb7a 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -26,11 +26,11 @@ 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'; -import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName'; import { PrivateContent } from '@app/components/shared/PrivateContent'; import UploadToServerModal from '@app/components/shared/UploadToServerModal'; import ShareFileModal from '@app/components/shared/ShareFileModal'; import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { truncateCenter } from '@app/utils/textUtils'; @@ -461,8 +461,8 @@ const FileEditorThumbnail = ({ marginTop: '0.5rem', marginBottom: '0.5rem', }}> - - + + {truncateCenter(file.name, 40)} "very-lo...ame.pdf" -function truncateCenter(text: string, maxLength: number = 25): string { - if (text.length <= maxLength) return text; - const ellipsis = '...'; - const charsToShow = maxLength - ellipsis.length; - const frontChars = Math.ceil(charsToShow / 2); - const backChars = Math.floor(charsToShow / 2); - return text.substring(0, frontChars) + ellipsis + text.substring(text.length - backChars); -} +import { truncateCenter } from '@app/utils/textUtils'; interface FileDropdownMenuProps { displayName: string; diff --git a/frontend/src/core/components/tools/shared/OperationButton.tsx b/frontend/src/core/components/tools/shared/OperationButton.tsx index 0e4399e48..7cc913a26 100644 --- a/frontend/src/core/components/tools/shared/OperationButton.tsx +++ b/frontend/src/core/components/tools/shared/OperationButton.tsx @@ -53,6 +53,7 @@ const OperationButton = ({ : t('tool.endpointUnavailable', 'This tool is unavailable on your server.'), noFiles: t('tool.noFiles', 'Add a file to get started.'), invalidParams: t('tool.invalidParams', 'Fill in the required settings.'), + viewerMode: t('tool.viewerMode', 'Switch to the file editor to select multiple files.'), }; const tooltipLabel = blockedByBackend diff --git a/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx b/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx new file mode 100644 index 000000000..a171383fa --- /dev/null +++ b/frontend/src/core/components/tools/shared/ScopedOperationButton.tsx @@ -0,0 +1,66 @@ +import { Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import OperationButton, { OperationButtonProps } from '@app/components/tools/shared/OperationButton'; +import { StirlingFile } from '@app/types/fileContext'; +import { useAllFiles } from '@app/contexts/FileContext'; +import { useViewer } from '@app/contexts/ViewerContext'; +import { useNavigationState } from '@app/contexts/NavigationContext'; + +export interface ScopedOperationButtonProps extends OperationButtonProps { + selectedFiles: StirlingFile[]; + disableScopeHints?: boolean; +} + +/** + * Wraps OperationButton with scope-aware button text and a filename note. + * + * - 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. + * - All other cases: no change to button text or layout. + */ +export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...props }: ScopedOperationButtonProps) { + const { t } = useTranslation(); + const { workbench } = useNavigationState(); + const { activeFileIndex } = useViewer(); + const { files: allFiles } = useAllFiles(); + + const isViewerMode = workbench === 'viewer'; + const isFileEditorMode = workbench === 'fileEditor'; + const hasMultipleFilesLoaded = allFiles.length > 1; + const baseText = props.submitText ?? t('submit', 'Submit'); + + const disabledForViewerMode = props.disabledReason === 'viewerMode'; + + let scopedText = baseText; + if (!disableScopeHints && !disabledForViewerMode) { + if (isViewerMode && hasMultipleFilesLoaded) { + scopedText = `${baseText} (${t('tool.scopeThisFile', 'this file')})`; + } else if (!isViewerMode && selectedFiles.length > 1) { + scopedText = `${baseText} (${selectedFiles.length} ${t('tool.scopeFiles', 'files')})`; + } + } + + const viewerFileName = !disableScopeHints && !disabledForViewerMode && isViewerMode && hasMultipleFilesLoaded + ? allFiles[activeFileIndex]?.name + : null; + + const showSelectFilesHint = !disableScopeHints && isFileEditorMode && allFiles.length > 0 && selectedFiles.length === 0; + + return ( + <> + + {viewerFileName && ( + + {t('tool.singleFileScope', 'Only applying to: {{fileName}}', { fileName: viewerFileName })} + + )} + {showSelectFilesHint && ( + + {t('tool.selectFilesHint', 'Select files in Active Files to run this tool')} + + )} + + ); +} diff --git a/frontend/src/core/components/tools/shared/createToolFlow.tsx b/frontend/src/core/components/tools/shared/createToolFlow.tsx index 0dcc72af8..caeb8b3fb 100644 --- a/frontend/src/core/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/core/components/tools/shared/createToolFlow.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Stack } from '@mantine/core'; import { createToolSteps, ToolStepProvider } from '@app/components/tools/shared/ToolStep'; -import OperationButton from '@app/components/tools/shared/OperationButton'; +import { ScopedOperationButton } from '@app/components/tools/shared/ScopedOperationButton'; import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle'; import { StirlingFile } from '@app/types/fileContext'; @@ -57,6 +57,8 @@ export interface ExecuteButtonConfig { disabled?: boolean; testId?: string; showCloudBadge?: boolean; + /** Suppress the automatic "(this file)" / "(N files)" scope hints in the button text. */ + disableScopeHints?: boolean; } export interface ReviewStepConfig { @@ -77,6 +79,8 @@ export interface ToolFlowConfig { // Optional preview content rendered between steps and the execute button preview?: React.ReactNode; executeButton?: ExecuteButtonConfig; + /** Optional content rendered immediately below the execute button (e.g. contextual help). */ + belowExecuteButton?: React.ReactNode; review: ReviewStepConfig; forceStepNumbers?: boolean; } @@ -128,17 +132,22 @@ export function createToolFlow(config: ToolFlowConfig + <> + + {config.belowExecuteButton} + ); })()} diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 5d9885232..6aa1eebd7 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -176,8 +176,6 @@ const EmbedPdfViewerContent = ({ // Similar to scroll preservation - track rotation across file reloads const pendingRotationRestoreRef = useRef(null); const rotationRestoreAttemptsRef = useRef(0); - // Track the file ID we should be viewing after a save (to handle list reordering) - const pendingFileIdRef = useRef(null); const formApplyInProgressRef = useRef(false); @@ -188,11 +186,12 @@ const EmbedPdfViewerContent = ({ const redactionTrackerRef = useRef(null); // Get current file from FileContext - const { selectors, state } = useFileState(); + const { selectors } = useFileState(); const { actions } = useFileActions(); const activeFiles = selectors.getFiles(); + const activeFilesRef = useRef(activeFiles); + activeFilesRef.current = activeFiles; const activeFileIds = activeFiles.map(f => f.fileId); - const selectedFileIds = state.ui.selectedFileIds; // Navigation guard for unsaved changes const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard(); @@ -277,41 +276,46 @@ const EmbedPdfViewerContent = ({ const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0); const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex; const setActiveFileIndex = externalSetActiveFileIndex ?? setInternalActiveFileIndex; - const hasInitializedFromSelection = useRef(false); - // When viewer opens with a selected file, switch to that file + // activeFileId (from ViewerContext) is the stable source of truth. + // We derive activeFileIndex from it so reorders after tool operations don't lose the viewed file. + const { activeFileId, setActiveFileId } = useViewer(); + + // Stable string key representing the current file list order. + // Using a joined ID string avoids depending on the activeFiles array reference, + // which is a new object every render and would cause an infinite effect loop. + const fileIdsKey = activeFiles.map(f => f.fileId).join(','); + + // When the file list actually changes, re-derive activeFileIndex from the stable activeFileId. useEffect(() => { - if (!hasInitializedFromSelection.current && selectedFileIds.length > 0 && activeFiles.length > 0) { - const selectedFileId = selectedFileIds[0]; - const index = activeFiles.findIndex(f => f.fileId === selectedFileId); - if (index !== -1 && index !== activeFileIndex) { - setActiveFileIndex(index); - } - hasInitializedFromSelection.current = true; + if (!activeFileId || activeFiles.length === 0) return; + const newIndex = activeFiles.findIndex(f => f.fileId === activeFileId); + if (newIndex !== -1 && newIndex !== activeFileIndex) { + setActiveFileIndex(newIndex); } - }, [selectedFileIds, activeFiles, activeFileIndex]); + }, [fileIdsKey, activeFileId]); // stable primitives — no infinite loop - // Reset active tab if it's out of bounds + // When the user manually switches file tabs, keep activeFileId in sync. + // Skips the initial mount to avoid overwriting an activeFileId set by handleViewFile. + const activeFileIndexMountedRef = useRef(false); + useEffect(() => { + if (!activeFileIndexMountedRef.current) { + activeFileIndexMountedRef.current = true; + return; + } + const fileId = activeFilesRef.current[activeFileIndex]?.fileId; + if (fileId && fileId !== activeFileId) { + setActiveFileId(fileId); + } + }, [activeFileIndex]); + + // Reset active tab if it's out of bounds (safety net) useEffect(() => { if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) { setActiveFileIndex(0); } }, [activeFiles.length, activeFileIndex]); - // After saving a file, the list may reorder (sorted by version). - // Track the saved file's ID and update activeFileIndex to follow it. - useEffect(() => { - if (pendingFileIdRef.current && activeFiles.length > 0) { - const targetFileId = pendingFileIdRef.current; - const newIndex = activeFiles.findIndex(f => f.fileId === targetFileId); - if (newIndex !== -1 && newIndex !== activeFileIndex) { - setActiveFileIndex(newIndex); - } - // Clear the pending file ID once we've found and switched to it - pendingFileIdRef.current = null; - } - }, [activeFiles, activeFileIndex, setActiveFileIndex]); - // Determine which file to display const currentFile = React.useMemo(() => { if (previewFile) { @@ -627,11 +631,9 @@ const EmbedPdfViewerContent = ({ // Store the rotation to restore after file replacement pendingRotationRestoreRef.current = currentRotation; rotationRestoreAttemptsRef.current = 0; - // Store the new file ID so we can track it after the list reorders + // Track the new file ID so the viewer follows it after the list reorders const newFileId = stubs[0]?.id; - if (newFileId) { - pendingFileIdRef.current = newFileId; - } + if (newFileId) setActiveFileId(newFileId); // Step 4: Consume only the current file (replace in context) await actions.consumeFiles([currentFileId], stirlingFiles, stubs); @@ -682,11 +684,9 @@ const EmbedPdfViewerContent = ({ pendingRotationRestoreRef.current = currentRotation; rotationRestoreAttemptsRef.current = 0; - // Store the new file ID for tracking + // Track the new file ID so the viewer follows it after the list reorders const newFileId = stubs[0]?.id; - if (newFileId) { - pendingFileIdRef.current = newFileId; - } + if (newFileId) setActiveFileId(newFileId); // Replace the current file in context await actions.consumeFiles([currentFileId], stirlingFiles, stubs); @@ -738,9 +738,7 @@ const EmbedPdfViewerContent = ({ rotationRestoreAttemptsRef.current = 0; const newFileId = stubs[0]?.id; - if (newFileId) { - pendingFileIdRef.current = newFileId; - } + if (newFileId) setActiveFileId(newFileId); await actions.consumeFiles([currentFileId], stirlingFiles, stubs); } catch (error) { diff --git a/frontend/src/core/contexts/NavigationContext.tsx b/frontend/src/core/contexts/NavigationContext.tsx index 17c8ca7b2..cde8c44ab 100644 --- a/frontend/src/core/contexts/NavigationContext.tsx +++ b/frontend/src/core/contexts/NavigationContext.tsx @@ -260,9 +260,11 @@ export const NavigationProvider: React.FC<{ return; } - // Look up the tool in the registry to get its proper workbench - const tool = isValidToolId(toolId)? toolRegistry[toolId] : null; - const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench(); + // Look up the tool in the registry to get its proper workbench. + // Regular tools have no workbench preference — keep the current view so + // opening a tool doesn't unexpectedly switch the user to the viewer. + const tool = isValidToolId(toolId) ? toolRegistry[toolId] : null; + const workbench = (tool && tool.workbench) ? tool.workbench : state.workbench; // Validate toolId and convert to ToolId type const validToolId = isValidToolId(toolId) ? toolId : null; diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index dbc74ddda..564dfa004 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -317,14 +317,12 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const validToolId = isValidToolId(toolId) ? toolId : null; actions.setSelectedTool(validToolId); - // Get the tool from registry to determine workbench + // Switch workbench only when required: leaving a custom view, or the tool declares one. const tool = getSelectedTool(toolId); if (wasInCustomWorkbench) { actions.setWorkbench(getDefaultWorkbench()); } else if (tool && tool.workbench) { actions.setWorkbench(tool.workbench); - } else { - actions.setWorkbench(getDefaultWorkbench()); } // Clear search query when selecting a tool @@ -342,8 +340,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { actions.setWorkbench(getDefaultWorkbench()); } else if (tool && tool.workbench) { actions.setWorkbench(tool.workbench); - } else { - actions.setWorkbench(getDefaultWorkbench()); } setSearchQuery(''); setLeftPanelView('toolContent'); diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 566288c84..8730c23f1 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -120,7 +120,9 @@ export interface ViewerContextType { isAnnotationMode: boolean; setAnnotationMode: (enabled: boolean) => void; - // Active file index for multi-file viewing + // Active file tracking — ID is the stable source of truth; index is derived from it + activeFileId: string | null; + setActiveFileId: (id: string | null) => void; activeFileIndex: number; setActiveFileIndex: (index: number) => void; @@ -204,6 +206,7 @@ export const ViewerProvider: React.FC = ({ children }) => { const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); + const [activeFileId, setActiveFileId] = useState(null); const [activeFileIndex, setActiveFileIndex] = useState(0); const [pdfRenderMode, setPdfRenderModeState] = useState( () => preferencesService.getPreference('pdfRenderMode') @@ -485,7 +488,9 @@ export const ViewerProvider: React.FC = ({ children }) => { isAnnotationMode, setAnnotationMode, - // Active file index + // Active file tracking + activeFileId, + setActiveFileId, activeFileIndex, setActiveFileIndex, diff --git a/frontend/src/core/hooks/tools/overlayPdfs/useOverlayPdfsParameters.ts b/frontend/src/core/hooks/tools/overlayPdfs/useOverlayPdfsParameters.ts index 056747e84..2f9073da0 100644 --- a/frontend/src/core/hooks/tools/overlayPdfs/useOverlayPdfsParameters.ts +++ b/frontend/src/core/hooks/tools/overlayPdfs/useOverlayPdfsParameters.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import { BaseParameters } from '@app/types/parameters'; import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; @@ -20,7 +21,7 @@ export const defaultParameters: OverlayPdfsParameters = { export type OverlayPdfsParametersHook = BaseParametersHook; export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => { - return useBaseParameters({ + const base = useBaseParameters({ defaultParameters, endpointName: 'overlay-pdfs', validateFn: (params) => { @@ -32,6 +33,18 @@ export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => { return true; }, }); + + // Overlay files are chosen independently of the base file selection, so they + // must survive the parameter reset that fires when the workbench selection + // transitions from 0 → 1+ files. Only mode/position/counts are reset. + const resetParameters = useCallback(() => { + base.setParameters(prev => ({ + ...defaultParameters, + overlayFiles: prev.overlayFiles, + })); + }, [base.setParameters]); + + return { ...base, resetParameters }; }; diff --git a/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts index 455c18ee5..a22c1dae8 100644 --- a/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -19,6 +19,7 @@ export type ExecuteDisabledReason = | 'endpointUnavailable' | 'noFiles' | 'invalidParams' + | 'viewerMode' | null; /** diff --git a/frontend/src/core/hooks/tools/shared/useBaseTool.ts b/frontend/src/core/hooks/tools/shared/useBaseTool.ts index f07689e0d..4b5cecd92 100644 --- a/frontend/src/core/hooks/tools/shared/useBaseTool.ts +++ b/frontend/src/core/hooks/tools/shared/useBaseTool.ts @@ -1,6 +1,6 @@ import { useEffect, useCallback, useRef } from 'react'; -import { useFileSelection } from '@app/contexts/FileContext'; import { useEndpointEnabled } from '@app/hooks/useEndpointConfig'; +import { useViewScopedFiles } from '@app/hooks/tools/shared/useViewScopedFiles'; import { BaseToolProps } from '@app/types/tool'; import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; import { BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; @@ -38,14 +38,24 @@ export function useBaseTool TParamsHook, useOperation: () => ToolOperationHook, props: BaseToolProps, - options?: { minFiles?: number } + options?: { + minFiles?: number; + /** When true, uses the full file selection rather than the viewer-scoped single file. */ + ignoreViewerScope?: boolean; + } ): BaseToolReturn { const minFiles = options?.minFiles ?? 1; + const ignoreViewerScope = options?.ignoreViewerScope ?? false; const { onPreviewFile, onComplete, onError } = props; - // File selection - const { selectedFiles } = useFileSelection(); - const previousFileCount = useRef(selectedFiles.length); + const viewerScopedFiles = useViewScopedFiles(ignoreViewerScope); + + // In viewer mode: scope to the single displayed file (unless ignoreViewerScope). + // In fileEditor with a selection: scope to the selected files. + // All other cases (pageEditor, custom, no selection): use all loaded files. + const effectiveFiles = viewerScopedFiles; + + const previousFileCount = useRef(effectiveFiles.length); // Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs) const skipNextSelectionResetRef = useRef(false); @@ -59,7 +69,7 @@ export function useBaseTool= minFiles; + const hasFiles = effectiveFiles.length >= minFiles; const hasResults = operation.files.length > 0 || operation.downloadUrl !== null; const settingsCollapsed = !hasFiles || hasResults; @@ -77,13 +87,13 @@ export function useBaseTool { - if (selectedFiles.length === 0) return; + if (effectiveFiles.length === 0) return; - const currentSelection = selectedFiles.map(f => f.fileId).sort().join(','); + const currentSelection = effectiveFiles.map(f => f.fileId).sort().join(','); - if (currentSelection === previousSelectionRef.current) return; // No change + if (currentSelection === previousSelectionRef.current) return; // Skip reset if this is the auto-selection after operation completed if (skipNextSelectionResetRef.current) { @@ -92,15 +102,14 @@ export function useBaseTool { - const currentFileCount = selectedFiles.length; + const currentFileCount = effectiveFiles.length; const prevFileCount = previousFileCount.current; if (prevFileCount === 0 && currentFileCount > 0) { @@ -108,12 +117,12 @@ export function useBaseTool { try { - await operation.executeOperation(params.parameters, selectedFiles); + await operation.executeOperation(params.parameters, effectiveFiles); if (operation.files && onComplete) { onComplete(operation.files); } @@ -123,7 +132,7 @@ export function useBaseTool { onPreviewFile?.(file); @@ -143,7 +152,7 @@ export function useBaseTool( const { t } = useTranslation(); const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext(); const { actions: navActions } = useNavigationActions(); + const viewerContext = useContext(ViewerContext); + const setActiveFileId = viewerContext?.setActiveFileId ?? (() => {}); // Composed hooks const { state, actions } = useToolState(); @@ -348,6 +351,9 @@ export const useToolOperation = ( const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id)); console.debug('[useToolOperation] Consuming files (version)', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); + // Tell the viewer to follow the replacement file — consumeFiles prepends the new file + // to the list, so activeFileIndex would point to the wrong file without this. + if (outputFileIds.length === 1) setActiveFileId(outputFileIds[0]); // Notify on desktop when processing completes await notifyPdfProcessingComplete(outputFileIds.length); diff --git a/frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts b/frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts new file mode 100644 index 000000000..03d652446 --- /dev/null +++ b/frontend/src/core/hooks/tools/shared/useViewScopedFiles.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { useAllFiles, useSelectedFiles } 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) { + const viewerFile = allFiles[activeFileIndex]; + return viewerFile ? [viewerFile] : allFiles; + } + + if (workbench === 'fileEditor') { + return selectedFiles; + } + + return allFiles; + }, [workbench, allFiles, activeFileIndex, selectedFiles, ignoreViewerScope]); +} diff --git a/frontend/src/core/tools/AddAttachments.tsx b/frontend/src/core/tools/AddAttachments.tsx index 036f0e7e3..8c8623782 100644 --- a/frontend/src/core/tools/AddAttachments.tsx +++ b/frontend/src/core/tools/AddAttachments.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; @@ -12,7 +12,7 @@ import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachment const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const addAttachmentsTips = useAddAttachmentsTips(); const params = useAddAttachmentsParameters(); diff --git a/frontend/src/core/tools/AddPageNumbers.tsx b/frontend/src/core/tools/AddPageNumbers.tsx index 1f7f09c78..6a700de09 100644 --- a/frontend/src/core/tools/AddPageNumbers.tsx +++ b/frontend/src/core/tools/AddPageNumbers.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; @@ -12,7 +12,7 @@ import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbe const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const params = useAddPageNumbersParameters(); const operation = useAddPageNumbersOperation(); diff --git a/frontend/src/core/tools/AddPassword.tsx b/frontend/src/core/tools/AddPassword.tsx index 72657ae80..a8b58d431 100644 --- a/frontend/src/core/tools/AddPassword.tsx +++ b/frontend/src/core/tools/AddPassword.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; @@ -16,7 +16,7 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool"; const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const [collapsedPermissions, setCollapsedPermissions] = useState(true); diff --git a/frontend/src/core/tools/AddStamp.tsx b/frontend/src/core/tools/AddStamp.tsx index b55a9be67..eb2fef794 100644 --- a/frontend/src/core/tools/AddStamp.tsx +++ b/frontend/src/core/tools/AddStamp.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useFileSelection } from "@app/contexts/FileContext"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { useAddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters"; import { useAddStampOperation } from "@app/components/tools/addStamp/useAddStampOperation"; import { Stack, Text } from "@mantine/core"; @@ -17,7 +17,7 @@ import StampPositionFormattingSettings from "@app/components/tools/addStamp/Stam const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false); const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true); diff --git a/frontend/src/core/tools/AddWatermark.tsx b/frontend/src/core/tools/AddWatermark.tsx index c0447e12d..3782663fd 100644 --- a/frontend/src/core/tools/AddWatermark.tsx +++ b/frontend/src/core/tools/AddWatermark.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; @@ -24,7 +24,7 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool"; const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const [collapsedType, setCollapsedType] = useState(false); const [collapsedStyle, setCollapsedStyle] = useState(true); diff --git a/frontend/src/core/tools/Compare.tsx b/frontend/src/core/tools/Compare.tsx index a2a78a036..f2deb6395 100644 --- a/frontend/src/core/tools/Compare.tsx +++ b/frontend/src/core/tools/Compare.tsx @@ -23,9 +23,9 @@ import type { FileId } from '@app/types/file'; import type { StirlingFile } from '@app/types/fileContext'; import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail'; import type { CompareWorkbenchData } from '@app/types/compare'; -import FitText from '@app/components/shared/FitText'; import { getDefaultWorkbench } from '@app/types/workbench'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; +import { truncateCenter } from '@app/utils/textUtils'; const CUSTOM_VIEW_ID = 'compareWorkbenchView'; const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const; @@ -48,7 +48,7 @@ const Compare = (props: BaseToolProps) => { useCompareParameters, useCompareOperation, props, - { minFiles: 2 } + { minFiles: 2, ignoreViewerScope: true } ); const operation = base.operation as CompareOperationHook; @@ -93,33 +93,57 @@ const Compare = (props: BaseToolProps) => { // Register once; avoid re-registering on translation/prop changes which clears data mid-flight }, []); - // Auto-map from workbench selection: always reflect the first two selected files in order. - // This also handles deselection by promoting the remaining selection to base and clearing comparison. + // On mount: clear selections unless exactly 2 files are loaded. useEffect(() => { - // Use selected IDs directly from state so it works even if File objects aren't loaded yet - const selectedIds = (fileState.ui.selectedFileIds as FileId[]) ?? []; + if ((fileState.files.ids as FileId[]).length !== 2) { + fileActions.clearSelections(); + } + }, []); - // Determine next base: keep current if still selected; otherwise use the first selected id - const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId) - ? (params.baseFileId as FileId) - : (selectedIds[0] ?? null); + // Track previous file count to detect the transition to exactly 2 files. + const prevAllIdsLengthRef = useRef(null); - // Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id - let nextComp: FileId | null = null; - if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) { - nextComp = params.comparisonFileId as FileId; - } else { - nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null; + // 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 (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) { - base.params.setParameters(prev => ({ - ...prev, - baseFileId: nextBase, - comparisonFileId: nextComp, - })); + if (selectedIds.length > 2) { + fileActions.setSelectedFiles(selectedIds.slice(0, 2) as FileId[]); + return; } - }, [fileState.ui.selectedFileIds, base.params, params.baseFileId, params.comparisonFileId]); + + const nextBase = (selectedIds[0] ?? null) as FileId | null; + const nextComp = (selectedIds[1] ?? null) as FileId | null; + base.params.setParameters(prev => { + if (prev.baseFileId === nextBase && prev.comparisonFileId === nextComp) return prev; + return { ...prev, baseFileId: nextBase, comparisonFileId: nextComp }; + }); + }, [fileState.ui.selectedFileIds, fileState.files.ids]); + + // Clear a slot if its file is removed from the workbench. + useEffect(() => { + const allIds = fileState.files.ids as FileId[]; + if (params.baseFileId && !allIds.includes(params.baseFileId as FileId)) { + base.params.setParameters(prev => ({ ...prev, baseFileId: null })); + } + if (params.comparisonFileId && !allIds.includes(params.comparisonFileId as FileId)) { + base.params.setParameters(prev => ({ ...prev, comparisonFileId: null })); + } + }, [fileState.files.ids]); // Track workbench data and drive loading/result state transitions const lastProcessedAtRef = useRef(null); @@ -397,14 +421,10 @@ const Compare = (props: BaseToolProps) => { - - + + + {truncateCenter(stub?.name || '', 50)} + {pageCount && dateText && ( <> @@ -576,6 +596,7 @@ const Compare = (props: BaseToolProps) => { onClick: handleExecuteCompare, disabled: !canExecute, testId: 'compare-execute', + disableScopeHints: true, }, review: { isVisible: false, diff --git a/frontend/src/core/tools/Convert.tsx b/frontend/src/core/tools/Convert.tsx index 53dd1be19..0ae04e2d7 100644 --- a/frontend/src/core/tools/Convert.tsx +++ b/frontend/src/core/tools/Convert.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileState, useFileSelection } from "@app/contexts/FileContext"; +import { useFileState } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; @@ -15,7 +16,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectors } = useFileState(); const activeFiles = selectors.getFiles(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const scrollContainerRef = useRef(null); const convertParams = useConvertParameters(); diff --git a/frontend/src/core/tools/Merge.tsx b/frontend/src/core/tools/Merge.tsx index 44dd5c89c..836cde404 100644 --- a/frontend/src/core/tools/Merge.tsx +++ b/frontend/src/core/tools/Merge.tsx @@ -1,5 +1,6 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { Button, Stack, Text } from "@mantine/core"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import MergeSettings from "@app/components/tools/merge/MergeSettings"; import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter"; @@ -9,6 +10,7 @@ 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 { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; const Merge = (props: BaseToolProps) => { const { t } = useTranslation(); @@ -24,8 +26,20 @@ const Merge = (props: BaseToolProps) => { useMergeParameters, useMergeOperation, props, - { minFiles: 2 } + { minFiles: 2, ignoreViewerScope: true } ); + + const { workbench } = useNavigationState(); + const { actions: navActions } = useNavigationActions(); + const isViewerMode = workbench === 'viewer'; + + const hasAutoSwitchedRef = useRef(false); + useEffect(() => { + if (isViewerMode && !hasAutoSwitchedRef.current) { + hasAutoSwitchedRef.current = true; + navActions.setWorkbench('fileEditor'); + } + }, []); const naturalCompare = useCallback((a: string, b: string): number => { const isDigit = (char: string) => char >= '0' && char <= '9'; @@ -136,7 +150,22 @@ const Merge = (props: BaseToolProps) => { onClick: base.handleExecute, endpointEnabled: base.endpointEnabled, paramsValid: base.params.validateParameters(), + disabledReason: isViewerMode ? 'viewerMode' : undefined, }, + belowExecuteButton: isViewerMode && !base.hasResults ? ( + + + {t("merge.viewerModeHint", "Merge needs 2 or more files. Head to the file editor to select them.")} + + + + ) : undefined, review: { isVisible: base.hasResults, operation: base.operation, diff --git a/frontend/src/core/tools/OCR.tsx b/frontend/src/core/tools/OCR.tsx index 07f05c812..8d71517a0 100644 --- a/frontend/src/core/tools/OCR.tsx +++ b/frontend/src/core/tools/OCR.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; @@ -16,7 +16,7 @@ import { useAdvancedOCRTips } from "@app/components/tooltips/useAdvancedOCRTips" const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); diff --git a/frontend/src/core/tools/ReorganizePages.tsx b/frontend/src/core/tools/ReorganizePages.tsx index d70f7a266..3d60a0d33 100644 --- a/frontend/src/core/tools/ReorganizePages.tsx +++ b/frontend/src/core/tools/ReorganizePages.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps"; import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings"; import { useReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters"; @@ -11,7 +11,7 @@ import { useReorganizePagesOperation } from "@app/hooks/tools/reorganizePages/us const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const params = useReorganizePagesParameters(); const operation = useReorganizePagesOperation(); diff --git a/frontend/src/core/utils/textUtils.ts b/frontend/src/core/utils/textUtils.ts index e033d505a..91f4db97e 100644 --- a/frontend/src/core/utils/textUtils.ts +++ b/frontend/src/core/utils/textUtils.ts @@ -1,3 +1,16 @@ +/** + * Truncates text from the centre, preserving the start and end. + * e.g. "very-long-filename.pdf" -> "very-lo...ame.pdf" + */ +export function truncateCenter(text: string, maxLength: number = 25): string { + if (text.length <= maxLength) return text; + const ellipsis = '...'; + const charsToShow = maxLength - ellipsis.length; + const frontChars = Math.ceil(charsToShow / 2); + const backChars = Math.floor(charsToShow / 2); + return text.substring(0, frontChars) + ellipsis + text.substring(text.length - backChars); +} + /** * Filters out emoji characters from a text string * @param text - The input text string