mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
## 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]>
175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
|
import { useFileState } from "@app/contexts/FileContext";
|
|
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
|
|
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
|
|
|
import ConvertSettings from "@app/components/tools/convert/ConvertSettings";
|
|
|
|
import { useConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
|
|
import { useConvertOperation } from "@app/hooks/tools/convert/useConvertOperation";
|
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
|
|
|
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|
const { t } = useTranslation();
|
|
const { selectors } = useFileState();
|
|
const activeFiles = selectors.getFiles();
|
|
const selectedFiles = useViewScopedFiles();
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const convertParams = useConvertParameters();
|
|
const convertOperation = useConvertOperation(convertParams.parameters);
|
|
|
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName());
|
|
|
|
// Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs)
|
|
const skipNextSelectionResetRef = useRef(false);
|
|
const previousSelectionRef = useRef<string>('');
|
|
|
|
const scrollToBottom = () => {
|
|
if (scrollContainerRef.current) {
|
|
scrollContainerRef.current.scrollTo({
|
|
top: scrollContainerRef.current.scrollHeight,
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
};
|
|
|
|
const hasFiles = selectedFiles.length > 0;
|
|
const hasResults = convertOperation.files.length > 0 || convertOperation.downloadUrl !== null || !!convertOperation.errorMessage;
|
|
const settingsCollapsed = hasResults;
|
|
|
|
// When operation completes, flag the next selection change to skip reset
|
|
useEffect(() => {
|
|
if (hasResults) {
|
|
skipNextSelectionResetRef.current = true;
|
|
}
|
|
}, [hasResults]);
|
|
|
|
// Reset results when user manually changes file selection
|
|
useEffect(() => {
|
|
const currentSelection = selectedFiles.map(f => f.fileId).sort().join(',');
|
|
|
|
if (currentSelection === previousSelectionRef.current) return; // No change
|
|
|
|
// Skip reset if this is the auto-selection after operation completed
|
|
// Don't analyze file types - would change parameters and trigger another reset
|
|
if (skipNextSelectionResetRef.current) {
|
|
skipNextSelectionResetRef.current = false;
|
|
previousSelectionRef.current = currentSelection;
|
|
return;
|
|
}
|
|
|
|
// User manually selected different files
|
|
if (selectedFiles.length > 0) {
|
|
previousSelectionRef.current = currentSelection;
|
|
convertParams.analyzeFileTypes(selectedFiles);
|
|
if (hasResults) {
|
|
convertOperation.resetResults();
|
|
onPreviewFile?.(null);
|
|
}
|
|
} else {
|
|
previousSelectionRef.current = '';
|
|
if (activeFiles.length === 0) {
|
|
convertParams.resetParameters();
|
|
}
|
|
}
|
|
}, [selectedFiles]);
|
|
|
|
useEffect(() => {
|
|
// Reset when user changes conversion parameters (but not during operation)
|
|
if (!convertOperation.isLoading && !skipNextSelectionResetRef.current) {
|
|
convertOperation.resetResults();
|
|
onPreviewFile?.(null);
|
|
}
|
|
}, [convertParams.parameters.fromExtension, convertParams.parameters.toExtension]);
|
|
|
|
useEffect(() => {
|
|
if (hasFiles) {
|
|
setTimeout(scrollToBottom, 100);
|
|
}
|
|
}, [hasFiles]);
|
|
|
|
useEffect(() => {
|
|
if (hasResults) {
|
|
setTimeout(scrollToBottom, 100);
|
|
}
|
|
}, [hasResults]);
|
|
|
|
const handleConvert = async () => {
|
|
try {
|
|
await convertOperation.executeOperation(convertParams.parameters, selectedFiles);
|
|
if (convertOperation.files && onComplete) {
|
|
onComplete(convertOperation.files);
|
|
}
|
|
} catch (error) {
|
|
if (onError) {
|
|
onError(error instanceof Error ? error.message : "Convert operation failed");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleThumbnailClick = (file: File) => {
|
|
onPreviewFile?.(file);
|
|
sessionStorage.setItem("previousMode", "convert");
|
|
};
|
|
|
|
const handleSettingsReset = () => {
|
|
skipNextSelectionResetRef.current = false;
|
|
convertOperation.resetResults();
|
|
onPreviewFile?.(null);
|
|
};
|
|
|
|
const handleUndo = async () => {
|
|
await convertOperation.undoOperation();
|
|
onPreviewFile?.(null);
|
|
};
|
|
|
|
return createToolFlow({
|
|
files: {
|
|
selectedFiles,
|
|
isCollapsed: hasResults,
|
|
},
|
|
steps: [
|
|
{
|
|
title: t("convert.settings", "Settings"),
|
|
isCollapsed: settingsCollapsed,
|
|
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
|
content: (
|
|
<ConvertSettings
|
|
parameters={convertParams.parameters}
|
|
onParameterChange={convertParams.updateParameter}
|
|
getAvailableToExtensions={convertParams.getAvailableToExtensions}
|
|
selectedFiles={selectedFiles}
|
|
disabled={endpointLoading}
|
|
/>
|
|
),
|
|
},
|
|
],
|
|
executeButton: {
|
|
text: t("convert.convertFiles", "Convert Files"),
|
|
loadingText: t("convert.converting", "Converting..."),
|
|
onClick: handleConvert,
|
|
isVisible: !hasResults,
|
|
endpointEnabled: endpointEnabled,
|
|
paramsValid: convertParams.validateParameters(),
|
|
testId: "convert-button",
|
|
},
|
|
review: {
|
|
isVisible: hasResults,
|
|
operation: convertOperation,
|
|
title: t("convert.conversionResults", "Conversion Results"),
|
|
onFileClick: handleThumbnailClick,
|
|
onUndo: handleUndo,
|
|
testId: "conversion-results",
|
|
},
|
|
});
|
|
};
|
|
|
|
// Static method to get the operation hook for automation
|
|
Convert.tool = () => useConvertOperation;
|
|
|
|
export default Convert as ToolComponent;
|