mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Add specific View Scope For Selected Files (#6050)
## 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 <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
parent
3c48740c5e
commit
917edc43b3
@@ -4422,6 +4422,8 @@ title = "Markdown To PDF"
|
|||||||
submit = "Merge"
|
submit = "Merge"
|
||||||
tags = "merge,Page operations,Back end,server side"
|
tags = "merge,Page operations,Back end,server side"
|
||||||
title = "Merge"
|
title = "Merge"
|
||||||
|
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them."
|
||||||
|
goToFileEditor = "Go to file editor"
|
||||||
|
|
||||||
[merge.error]
|
[merge.error]
|
||||||
failed = "An error occurred while merging the PDFs."
|
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."
|
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
|
||||||
invalidParams = "Fill in the required settings."
|
invalidParams = "Fill in the required settings."
|
||||||
noFiles = "Add a file to get started."
|
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]
|
[tools]
|
||||||
noSearchResults = "No tools found"
|
noSearchResults = "No tools found"
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ const FileEditor = ({
|
|||||||
// Get navigation actions
|
// Get navigation actions
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
|
|
||||||
// Get viewer context for setting active file index
|
// Get viewer context for setting active file index and ID
|
||||||
const { setActiveFileIndex } = useViewer();
|
const { setActiveFileIndex, setActiveFileId } = useViewer();
|
||||||
|
|
||||||
// Get file selection context
|
// Get file selection context
|
||||||
const { setSelectedFiles } = useFileSelection();
|
const { setSelectedFiles } = useFileSelection();
|
||||||
@@ -351,12 +351,11 @@ const FileEditor = ({
|
|||||||
const handleViewFile = useCallback((fileId: FileId) => {
|
const handleViewFile = useCallback((fileId: FileId) => {
|
||||||
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
// Set the file as selected in context, sync the viewer index, and switch to viewer
|
setActiveFileId(fileId as string);
|
||||||
setSelectedFiles([fileId]);
|
|
||||||
setActiveFileIndex(index);
|
setActiveFileIndex(index);
|
||||||
navActions.setWorkbench('viewer');
|
navActions.setWorkbench('viewer');
|
||||||
}
|
}
|
||||||
}, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions.setWorkbench]);
|
}, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]);
|
||||||
|
|
||||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ import { formatFileSize } from '@app/utils/fileUtils';
|
|||||||
import ToolChain from '@app/components/shared/ToolChain';
|
import ToolChain from '@app/components/shared/ToolChain';
|
||||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||||
import { downloadFile } from '@app/services/downloadService';
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
|
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
||||||
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
import { truncateCenter } from '@app/utils/textUtils';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -461,8 +461,8 @@ const FileEditorThumbnail = ({
|
|||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
marginBottom: '0.5rem',
|
marginBottom: '0.5rem',
|
||||||
}}>
|
}}>
|
||||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
|
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
||||||
<FileEditorFileName file={file} />
|
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -6,16 +6,7 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import FitText from '@app/components/shared/FitText';
|
import FitText from '@app/components/shared/FitText';
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
import { FileId } from '@app/types/file';
|
import { FileId } from '@app/types/file';
|
||||||
|
import { truncateCenter } from '@app/utils/textUtils';
|
||||||
// Truncate text from the center: "very-long-filename.pdf" -> "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);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileDropdownMenuProps {
|
interface FileDropdownMenuProps {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const OperationButton = ({
|
|||||||
: t('tool.endpointUnavailable', 'This tool is unavailable on your server.'),
|
: t('tool.endpointUnavailable', 'This tool is unavailable on your server.'),
|
||||||
noFiles: t('tool.noFiles', 'Add a file to get started.'),
|
noFiles: t('tool.noFiles', 'Add a file to get started.'),
|
||||||
invalidParams: t('tool.invalidParams', 'Fill in the required settings.'),
|
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
|
const tooltipLabel = blockedByBackend
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<OperationButton {...props} submitText={scopedText} />
|
||||||
|
{viewerFileName && (
|
||||||
|
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Stack } from '@mantine/core';
|
import { Stack } from '@mantine/core';
|
||||||
import { createToolSteps, ToolStepProvider } from '@app/components/tools/shared/ToolStep';
|
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 { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
|
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
|
||||||
import { StirlingFile } from '@app/types/fileContext';
|
import { StirlingFile } from '@app/types/fileContext';
|
||||||
@@ -57,6 +57,8 @@ export interface ExecuteButtonConfig {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
showCloudBadge?: boolean;
|
showCloudBadge?: boolean;
|
||||||
|
/** Suppress the automatic "(this file)" / "(N files)" scope hints in the button text. */
|
||||||
|
disableScopeHints?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewStepConfig<TParams = unknown> {
|
export interface ReviewStepConfig<TParams = unknown> {
|
||||||
@@ -77,6 +79,8 @@ export interface ToolFlowConfig<TParams = unknown> {
|
|||||||
// Optional preview content rendered between steps and the execute button
|
// Optional preview content rendered between steps and the execute button
|
||||||
preview?: React.ReactNode;
|
preview?: React.ReactNode;
|
||||||
executeButton?: ExecuteButtonConfig;
|
executeButton?: ExecuteButtonConfig;
|
||||||
|
/** Optional content rendered immediately below the execute button (e.g. contextual help). */
|
||||||
|
belowExecuteButton?: React.ReactNode;
|
||||||
review: ReviewStepConfig<TParams>;
|
review: ReviewStepConfig<TParams>;
|
||||||
forceStepNumbers?: boolean;
|
forceStepNumbers?: boolean;
|
||||||
}
|
}
|
||||||
@@ -128,7 +132,10 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
|
|||||||
: eb.paramsValid === false ? 'invalidParams'
|
: eb.paramsValid === false ? 'invalidParams'
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<OperationButton
|
<>
|
||||||
|
<ScopedOperationButton
|
||||||
|
selectedFiles={config.files.selectedFiles ?? []}
|
||||||
|
disableScopeHints={eb.disableScopeHints}
|
||||||
onClick={eb.onClick}
|
onClick={eb.onClick}
|
||||||
isLoading={config.review.operation.isLoading}
|
isLoading={config.review.operation.isLoading}
|
||||||
disabled={eb.disabled}
|
disabled={eb.disabled}
|
||||||
@@ -139,6 +146,8 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
|
|||||||
data-testid={eb.testId}
|
data-testid={eb.testId}
|
||||||
data-tour="run-button"
|
data-tour="run-button"
|
||||||
/>
|
/>
|
||||||
|
{config.belowExecuteButton}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
|||||||
@@ -176,8 +176,6 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Similar to scroll preservation - track rotation across file reloads
|
// Similar to scroll preservation - track rotation across file reloads
|
||||||
const pendingRotationRestoreRef = useRef<number | null>(null);
|
const pendingRotationRestoreRef = useRef<number | null>(null);
|
||||||
const rotationRestoreAttemptsRef = useRef<number>(0);
|
const rotationRestoreAttemptsRef = useRef<number>(0);
|
||||||
// Track the file ID we should be viewing after a save (to handle list reordering)
|
|
||||||
const pendingFileIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const formApplyInProgressRef = useRef(false);
|
const formApplyInProgressRef = useRef(false);
|
||||||
|
|
||||||
@@ -188,11 +186,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
|
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
|
||||||
|
|
||||||
// Get current file from FileContext
|
// Get current file from FileContext
|
||||||
const { selectors, state } = useFileState();
|
const { selectors } = useFileState();
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
const activeFiles = selectors.getFiles();
|
const activeFiles = selectors.getFiles();
|
||||||
|
const activeFilesRef = useRef(activeFiles);
|
||||||
|
activeFilesRef.current = activeFiles;
|
||||||
const activeFileIds = activeFiles.map(f => f.fileId);
|
const activeFileIds = activeFiles.map(f => f.fileId);
|
||||||
const selectedFileIds = state.ui.selectedFileIds;
|
|
||||||
|
|
||||||
// Navigation guard for unsaved changes
|
// Navigation guard for unsaved changes
|
||||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard();
|
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard();
|
||||||
@@ -277,41 +276,46 @@ const EmbedPdfViewerContent = ({
|
|||||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||||
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
|
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
|
||||||
const setActiveFileIndex = externalSetActiveFileIndex ?? setInternalActiveFileIndex;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hasInitializedFromSelection.current && selectedFileIds.length > 0 && activeFiles.length > 0) {
|
if (!activeFileId || activeFiles.length === 0) return;
|
||||||
const selectedFileId = selectedFileIds[0];
|
const newIndex = activeFiles.findIndex(f => f.fileId === activeFileId);
|
||||||
const index = activeFiles.findIndex(f => f.fileId === selectedFileId);
|
if (newIndex !== -1 && newIndex !== activeFileIndex) {
|
||||||
if (index !== -1 && index !== activeFileIndex) {
|
setActiveFileIndex(newIndex);
|
||||||
setActiveFileIndex(index);
|
|
||||||
}
|
}
|
||||||
hasInitializedFromSelection.current = true;
|
}, [fileIdsKey, activeFileId]); // stable primitives — no infinite loop
|
||||||
}
|
|
||||||
}, [selectedFileIds, activeFiles, activeFileIndex]);
|
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
|
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
|
||||||
setActiveFileIndex(0);
|
setActiveFileIndex(0);
|
||||||
}
|
}
|
||||||
}, [activeFiles.length, activeFileIndex]);
|
}, [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
|
// Determine which file to display
|
||||||
const currentFile = React.useMemo(() => {
|
const currentFile = React.useMemo(() => {
|
||||||
if (previewFile) {
|
if (previewFile) {
|
||||||
@@ -627,11 +631,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
// Store the rotation to restore after file replacement
|
// Store the rotation to restore after file replacement
|
||||||
pendingRotationRestoreRef.current = currentRotation;
|
pendingRotationRestoreRef.current = currentRotation;
|
||||||
rotationRestoreAttemptsRef.current = 0;
|
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;
|
const newFileId = stubs[0]?.id;
|
||||||
if (newFileId) {
|
if (newFileId) setActiveFileId(newFileId);
|
||||||
pendingFileIdRef.current = newFileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Consume only the current file (replace in context)
|
// Step 4: Consume only the current file (replace in context)
|
||||||
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||||
@@ -682,11 +684,9 @@ const EmbedPdfViewerContent = ({
|
|||||||
pendingRotationRestoreRef.current = currentRotation;
|
pendingRotationRestoreRef.current = currentRotation;
|
||||||
rotationRestoreAttemptsRef.current = 0;
|
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;
|
const newFileId = stubs[0]?.id;
|
||||||
if (newFileId) {
|
if (newFileId) setActiveFileId(newFileId);
|
||||||
pendingFileIdRef.current = newFileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace the current file in context
|
// Replace the current file in context
|
||||||
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||||
@@ -738,9 +738,7 @@ const EmbedPdfViewerContent = ({
|
|||||||
rotationRestoreAttemptsRef.current = 0;
|
rotationRestoreAttemptsRef.current = 0;
|
||||||
|
|
||||||
const newFileId = stubs[0]?.id;
|
const newFileId = stubs[0]?.id;
|
||||||
if (newFileId) {
|
if (newFileId) setActiveFileId(newFileId);
|
||||||
pendingFileIdRef.current = newFileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -260,9 +260,11 @@ export const NavigationProvider: React.FC<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the tool in the registry to get its proper workbench
|
// 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 tool = isValidToolId(toolId) ? toolRegistry[toolId] : null;
|
||||||
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
|
const workbench = (tool && tool.workbench) ? tool.workbench : state.workbench;
|
||||||
|
|
||||||
// Validate toolId and convert to ToolId type
|
// Validate toolId and convert to ToolId type
|
||||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||||
|
|||||||
@@ -317,14 +317,12 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||||
actions.setSelectedTool(validToolId);
|
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);
|
const tool = getSelectedTool(toolId);
|
||||||
if (wasInCustomWorkbench) {
|
if (wasInCustomWorkbench) {
|
||||||
actions.setWorkbench(getDefaultWorkbench());
|
actions.setWorkbench(getDefaultWorkbench());
|
||||||
} else if (tool && tool.workbench) {
|
} else if (tool && tool.workbench) {
|
||||||
actions.setWorkbench(tool.workbench);
|
actions.setWorkbench(tool.workbench);
|
||||||
} else {
|
|
||||||
actions.setWorkbench(getDefaultWorkbench());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear search query when selecting a tool
|
// Clear search query when selecting a tool
|
||||||
@@ -342,8 +340,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
actions.setWorkbench(getDefaultWorkbench());
|
actions.setWorkbench(getDefaultWorkbench());
|
||||||
} else if (tool && tool.workbench) {
|
} else if (tool && tool.workbench) {
|
||||||
actions.setWorkbench(tool.workbench);
|
actions.setWorkbench(tool.workbench);
|
||||||
} else {
|
|
||||||
actions.setWorkbench(getDefaultWorkbench());
|
|
||||||
}
|
}
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setLeftPanelView('toolContent');
|
setLeftPanelView('toolContent');
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ export interface ViewerContextType {
|
|||||||
isAnnotationMode: boolean;
|
isAnnotationMode: boolean;
|
||||||
setAnnotationMode: (enabled: boolean) => void;
|
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;
|
activeFileIndex: number;
|
||||||
setActiveFileIndex: (index: number) => void;
|
setActiveFileIndex: (index: number) => void;
|
||||||
|
|
||||||
@@ -204,6 +206,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
|
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
|
||||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||||
|
const [activeFileId, setActiveFileId] = useState<string | null>(null);
|
||||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||||
const [pdfRenderMode, setPdfRenderModeState] = useState<PdfRenderMode>(
|
const [pdfRenderMode, setPdfRenderModeState] = useState<PdfRenderMode>(
|
||||||
() => preferencesService.getPreference('pdfRenderMode')
|
() => preferencesService.getPreference('pdfRenderMode')
|
||||||
@@ -485,7 +488,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
isAnnotationMode,
|
isAnnotationMode,
|
||||||
setAnnotationMode,
|
setAnnotationMode,
|
||||||
|
|
||||||
// Active file index
|
// Active file tracking
|
||||||
|
activeFileId,
|
||||||
|
setActiveFileId,
|
||||||
activeFileIndex,
|
activeFileIndex,
|
||||||
setActiveFileIndex,
|
setActiveFileIndex,
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
import { BaseParameters } from '@app/types/parameters';
|
import { BaseParameters } from '@app/types/parameters';
|
||||||
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ export const defaultParameters: OverlayPdfsParameters = {
|
|||||||
export type OverlayPdfsParametersHook = BaseParametersHook<OverlayPdfsParameters>;
|
export type OverlayPdfsParametersHook = BaseParametersHook<OverlayPdfsParameters>;
|
||||||
|
|
||||||
export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
|
export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
|
||||||
return useBaseParameters<OverlayPdfsParameters>({
|
const base = useBaseParameters<OverlayPdfsParameters>({
|
||||||
defaultParameters,
|
defaultParameters,
|
||||||
endpointName: 'overlay-pdfs',
|
endpointName: 'overlay-pdfs',
|
||||||
validateFn: (params) => {
|
validateFn: (params) => {
|
||||||
@@ -32,6 +33,18 @@ export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
|
|||||||
return true;
|
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 };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type ExecuteDisabledReason =
|
|||||||
| 'endpointUnavailable'
|
| 'endpointUnavailable'
|
||||||
| 'noFiles'
|
| 'noFiles'
|
||||||
| 'invalidParams'
|
| 'invalidParams'
|
||||||
|
| 'viewerMode'
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useCallback, useRef } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
import { useFileSelection } from '@app/contexts/FileContext';
|
|
||||||
import { useEndpointEnabled } from '@app/hooks/useEndpointConfig';
|
import { useEndpointEnabled } from '@app/hooks/useEndpointConfig';
|
||||||
|
import { useViewScopedFiles } from '@app/hooks/tools/shared/useViewScopedFiles';
|
||||||
import { BaseToolProps } from '@app/types/tool';
|
import { BaseToolProps } from '@app/types/tool';
|
||||||
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||||
import { BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
import { BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
||||||
@@ -38,14 +38,24 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
useParams: () => TParamsHook,
|
useParams: () => TParamsHook,
|
||||||
useOperation: () => ToolOperationHook<TParams>,
|
useOperation: () => ToolOperationHook<TParams>,
|
||||||
props: BaseToolProps,
|
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<TParams, TParamsHook> {
|
): BaseToolReturn<TParams, TParamsHook> {
|
||||||
const minFiles = options?.minFiles ?? 1;
|
const minFiles = options?.minFiles ?? 1;
|
||||||
|
const ignoreViewerScope = options?.ignoreViewerScope ?? false;
|
||||||
const { onPreviewFile, onComplete, onError } = props;
|
const { onPreviewFile, onComplete, onError } = props;
|
||||||
|
|
||||||
// File selection
|
const viewerScopedFiles = useViewScopedFiles(ignoreViewerScope);
|
||||||
const { selectedFiles } = useFileSelection();
|
|
||||||
const previousFileCount = useRef(selectedFiles.length);
|
// 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)
|
// Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs)
|
||||||
const skipNextSelectionResetRef = useRef(false);
|
const skipNextSelectionResetRef = useRef(false);
|
||||||
@@ -59,7 +69,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
|
||||||
|
|
||||||
// Standard computed state - defined early so it's available in useEffects
|
// Standard computed state - defined early so it's available in useEffects
|
||||||
const hasFiles = selectedFiles.length >= minFiles;
|
const hasFiles = effectiveFiles.length >= minFiles;
|
||||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||||
const settingsCollapsed = !hasFiles || hasResults;
|
const settingsCollapsed = !hasFiles || hasResults;
|
||||||
|
|
||||||
@@ -77,13 +87,13 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
}
|
}
|
||||||
}, [hasResults]);
|
}, [hasResults]);
|
||||||
|
|
||||||
// Reset results when user manually changes file selection
|
// Reset results when effective files change (viewer file switch or selection change)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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
|
// Skip reset if this is the auto-selection after operation completed
|
||||||
if (skipNextSelectionResetRef.current) {
|
if (skipNextSelectionResetRef.current) {
|
||||||
@@ -92,15 +102,14 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User manually selected different files - reset results
|
|
||||||
previousSelectionRef.current = currentSelection;
|
previousSelectionRef.current = currentSelection;
|
||||||
operation.resetResults();
|
operation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [selectedFiles]);
|
}, [effectiveFiles]);
|
||||||
|
|
||||||
// Reset parameters when transitioning from 0 files to at least 1 file
|
// Reset parameters when transitioning from 0 files to at least 1 file
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentFileCount = selectedFiles.length;
|
const currentFileCount = effectiveFiles.length;
|
||||||
const prevFileCount = previousFileCount.current;
|
const prevFileCount = previousFileCount.current;
|
||||||
|
|
||||||
if (prevFileCount === 0 && currentFileCount > 0) {
|
if (prevFileCount === 0 && currentFileCount > 0) {
|
||||||
@@ -108,12 +117,12 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
}
|
}
|
||||||
|
|
||||||
previousFileCount.current = currentFileCount;
|
previousFileCount.current = currentFileCount;
|
||||||
}, [selectedFiles.length]);
|
}, [effectiveFiles.length]);
|
||||||
|
|
||||||
// Standard handlers
|
// Standard handlers
|
||||||
const handleExecute = useCallback(async () => {
|
const handleExecute = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await operation.executeOperation(params.parameters, selectedFiles);
|
await operation.executeOperation(params.parameters, effectiveFiles);
|
||||||
if (operation.files && onComplete) {
|
if (operation.files && onComplete) {
|
||||||
onComplete(operation.files);
|
onComplete(operation.files);
|
||||||
}
|
}
|
||||||
@@ -123,7 +132,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
onError(message);
|
onError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [operation, params.parameters, selectedFiles, onComplete, onError, toolName]);
|
}, [operation, params.parameters, effectiveFiles, onComplete, onError, toolName]);
|
||||||
|
|
||||||
const handleThumbnailClick = useCallback((file: File) => {
|
const handleThumbnailClick = useCallback((file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
@@ -143,7 +152,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// File management
|
// File management
|
||||||
selectedFiles,
|
selectedFiles: effectiveFiles,
|
||||||
|
|
||||||
// Tool-specific hooks
|
// Tool-specific hooks
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useRef, useEffect } from 'react';
|
import { useCallback, useRef, useEffect, useContext } from 'react';
|
||||||
import apiClient from '@app/services/apiClient';
|
import apiClient from '@app/services/apiClient';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '@app/contexts/FileContext';
|
import { useFileContext } from '@app/contexts/FileContext';
|
||||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||||
|
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||||
import { useToolState } from '@app/hooks/tools/shared/useToolState';
|
import { useToolState } from '@app/hooks/tools/shared/useToolState';
|
||||||
import { useToolApiCalls, type ApiCallsConfig } from '@app/hooks/tools/shared/useToolApiCalls';
|
import { useToolApiCalls, type ApiCallsConfig } from '@app/hooks/tools/shared/useToolApiCalls';
|
||||||
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
|
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
|
||||||
@@ -63,6 +64,8 @@ export const useToolOperation = <TParams>(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||||
const { actions: navActions } = useNavigationActions();
|
const { actions: navActions } = useNavigationActions();
|
||||||
|
const viewerContext = useContext(ViewerContext);
|
||||||
|
const setActiveFileId = viewerContext?.setActiveFileId ?? (() => {});
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
@@ -348,6 +351,9 @@ export const useToolOperation = <TParams>(
|
|||||||
const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id));
|
const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id));
|
||||||
console.debug('[useToolOperation] Consuming files (version)', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
console.debug('[useToolOperation] Consuming files (version)', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
||||||
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
|
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
|
// Notify on desktop when processing completes
|
||||||
await notifyPdfProcessingComplete(outputFileIds.length);
|
await notifyPdfProcessingComplete(outputFileIds.length);
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||||
@@ -12,7 +12,7 @@ import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachment
|
|||||||
|
|
||||||
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
const addAttachmentsTips = useAddAttachmentsTips();
|
const addAttachmentsTips = useAddAttachmentsTips();
|
||||||
|
|
||||||
const params = useAddAttachmentsParameters();
|
const params = useAddAttachmentsParameters();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||||
@@ -12,7 +12,7 @@ import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbe
|
|||||||
|
|
||||||
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
|
|
||||||
const params = useAddPageNumbersParameters();
|
const params = useAddPageNumbersParameters();
|
||||||
const operation = useAddPageNumbersOperation();
|
const operation = useAddPageNumbersOperation();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
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";
|
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 AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
|
|
||||||
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileSelection } from "@app/contexts/FileContext";
|
|
||||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||||
|
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||||
import { useAddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters";
|
import { useAddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters";
|
||||||
import { useAddStampOperation } from "@app/components/tools/addStamp/useAddStampOperation";
|
import { useAddStampOperation } from "@app/components/tools/addStamp/useAddStampOperation";
|
||||||
import { Stack, Text } from "@mantine/core";
|
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 AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
|
|
||||||
const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false);
|
const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false);
|
||||||
const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true);
|
const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
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";
|
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 AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
|
|
||||||
const [collapsedType, setCollapsedType] = useState(false);
|
const [collapsedType, setCollapsedType] = useState(false);
|
||||||
const [collapsedStyle, setCollapsedStyle] = useState(true);
|
const [collapsedStyle, setCollapsedStyle] = useState(true);
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import type { FileId } from '@app/types/file';
|
|||||||
import type { StirlingFile } from '@app/types/fileContext';
|
import type { StirlingFile } from '@app/types/fileContext';
|
||||||
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
|
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
|
||||||
import type { CompareWorkbenchData } from '@app/types/compare';
|
import type { CompareWorkbenchData } from '@app/types/compare';
|
||||||
import FitText from '@app/components/shared/FitText';
|
|
||||||
import { getDefaultWorkbench } from '@app/types/workbench';
|
import { getDefaultWorkbench } from '@app/types/workbench';
|
||||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||||
|
import { truncateCenter } from '@app/utils/textUtils';
|
||||||
|
|
||||||
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
|
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
|
||||||
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
|
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
|
||||||
@@ -48,7 +48,7 @@ const Compare = (props: BaseToolProps) => {
|
|||||||
useCompareParameters,
|
useCompareParameters,
|
||||||
useCompareOperation,
|
useCompareOperation,
|
||||||
props,
|
props,
|
||||||
{ minFiles: 2 }
|
{ minFiles: 2, ignoreViewerScope: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const operation = base.operation as CompareOperationHook;
|
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
|
// 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.
|
// On mount: clear selections unless exactly 2 files are loaded.
|
||||||
// This also handles deselection by promoting the remaining selection to base and clearing comparison.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use selected IDs directly from state so it works even if File objects aren't loaded yet
|
if ((fileState.files.ids as FileId[]).length !== 2) {
|
||||||
const selectedIds = (fileState.ui.selectedFileIds as FileId[]) ?? [];
|
fileActions.clearSelections();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Determine next base: keep current if still selected; otherwise use the first selected id
|
// Track previous file count to detect the transition to exactly 2 files.
|
||||||
const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId)
|
const prevAllIdsLengthRef = useRef<number | null>(null);
|
||||||
? (params.baseFileId as FileId)
|
|
||||||
: (selectedIds[0] ?? null);
|
|
||||||
|
|
||||||
// Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id
|
// Auto-fill slots when the file count first reaches 2; respect manual picker changes after that.
|
||||||
let nextComp: FileId | null = null;
|
useEffect(() => {
|
||||||
if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) {
|
const selectedIds = fileState.ui.selectedFileIds as FileId[];
|
||||||
nextComp = params.comparisonFileId as FileId;
|
const allIds = fileState.files.ids as FileId[];
|
||||||
} else {
|
const prevLength = prevAllIdsLengthRef.current;
|
||||||
nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null;
|
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) {
|
if (selectedIds.length > 2) {
|
||||||
base.params.setParameters(prev => ({
|
fileActions.setSelectedFiles(selectedIds.slice(0, 2) as FileId[]);
|
||||||
...prev,
|
return;
|
||||||
baseFileId: nextBase,
|
|
||||||
comparisonFileId: nextComp,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}, [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
|
// Track workbench data and drive loading/result state transitions
|
||||||
const lastProcessedAtRef = useRef<number | null>(null);
|
const lastProcessedAtRef = useRef<number | null>(null);
|
||||||
@@ -397,14 +421,10 @@ const Compare = (props: BaseToolProps) => {
|
|||||||
<Box className="compare-tool__thumbnail" style={{ alignSelf: 'center' }}>
|
<Box className="compare-tool__thumbnail" style={{ alignSelf: 'center' }}>
|
||||||
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
|
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
|
||||||
</Box>
|
</Box>
|
||||||
<Stack className="compare-tool__details">
|
<Stack className="compare-tool__details" style={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
|
||||||
<FitText
|
<Text fw={600} title={stub?.name}>
|
||||||
text={stub?.name || ''}
|
{truncateCenter(stub?.name || '', 50)}
|
||||||
minimumFontScale={0.8}
|
</Text>
|
||||||
lines={3}
|
|
||||||
style={{ fontWeight: 600
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{pageCount && dateText && (
|
{pageCount && dateText && (
|
||||||
<>
|
<>
|
||||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
@@ -576,6 +596,7 @@ const Compare = (props: BaseToolProps) => {
|
|||||||
onClick: handleExecuteCompare,
|
onClick: handleExecuteCompare,
|
||||||
disabled: !canExecute,
|
disabled: !canExecute,
|
||||||
testId: 'compare-execute',
|
testId: 'compare-execute',
|
||||||
|
disableScopeHints: true,
|
||||||
},
|
},
|
||||||
review: {
|
review: {
|
||||||
isVisible: false,
|
isVisible: false,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
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";
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
const activeFiles = selectors.getFiles();
|
const activeFiles = selectors.getFiles();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const convertParams = useConvertParameters();
|
const convertParams = useConvertParameters();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button, Stack, Text } from "@mantine/core";
|
||||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
import MergeSettings from "@app/components/tools/merge/MergeSettings";
|
import MergeSettings from "@app/components/tools/merge/MergeSettings";
|
||||||
import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter";
|
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 { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
import { useMergeTips } from "@app/components/tooltips/useMergeTips";
|
import { useMergeTips } from "@app/components/tooltips/useMergeTips";
|
||||||
import { useFileManagement, useSelectedFiles, useAllFiles } from "@app/contexts/FileContext";
|
import { useFileManagement, useSelectedFiles, useAllFiles } from "@app/contexts/FileContext";
|
||||||
|
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
|
||||||
|
|
||||||
const Merge = (props: BaseToolProps) => {
|
const Merge = (props: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,8 +26,20 @@ const Merge = (props: BaseToolProps) => {
|
|||||||
useMergeParameters,
|
useMergeParameters,
|
||||||
useMergeOperation,
|
useMergeOperation,
|
||||||
props,
|
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 naturalCompare = useCallback((a: string, b: string): number => {
|
||||||
const isDigit = (char: string) => char >= '0' && char <= '9';
|
const isDigit = (char: string) => char >= '0' && char <= '9';
|
||||||
|
|
||||||
@@ -136,7 +150,22 @@ const Merge = (props: BaseToolProps) => {
|
|||||||
onClick: base.handleExecute,
|
onClick: base.handleExecute,
|
||||||
endpointEnabled: base.endpointEnabled,
|
endpointEnabled: base.endpointEnabled,
|
||||||
paramsValid: base.params.validateParameters(),
|
paramsValid: base.params.validateParameters(),
|
||||||
|
disabledReason: isViewerMode ? 'viewerMode' : undefined,
|
||||||
},
|
},
|
||||||
|
belowExecuteButton: isViewerMode && !base.hasResults ? (
|
||||||
|
<Stack align="center" gap={6} mx="md" mt={4}>
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
{t("merge.viewerModeHint", "Merge needs 2 or more files. Head to the file editor to select them.")}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => navActions.setWorkbench('fileEditor')}
|
||||||
|
>
|
||||||
|
{t("merge.goToFileEditor", "Go to file editor")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : undefined,
|
||||||
review: {
|
review: {
|
||||||
isVisible: base.hasResults,
|
isVisible: base.hasResults,
|
||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
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";
|
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 OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
|
|
||||||
const ocrParams = useOCRParameters();
|
const ocrParams = useOCRParameters();
|
||||||
const ocrOperation = useOCROperation();
|
const ocrOperation = useOCROperation();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
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 { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||||
import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings";
|
import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings";
|
||||||
import { useReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters";
|
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 ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useFileSelection();
|
const selectedFiles = useViewScopedFiles();
|
||||||
|
|
||||||
const params = useReorganizePagesParameters();
|
const params = useReorganizePagesParameters();
|
||||||
const operation = useReorganizePagesOperation();
|
const operation = useReorganizePagesOperation();
|
||||||
|
|||||||
@@ -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
|
* Filters out emoji characters from a text string
|
||||||
* @param text - The input text string
|
* @param text - The input text string
|
||||||
|
|||||||
Reference in New Issue
Block a user