e.stopPropagation()}
>
+ {/* Pin/Unpin button */}
+
+ {
+ e.stopPropagation();
+ if (isPinned) {
+ unpinFile(file as File);
+ } else {
+ pinFile(file as File);
+ }
+ }}
+ >
+ {isPinned ? : }
+
+
+
{onView && (
{
+ const { pinFile, unpinFile, isFilePinned } = useFileContext();
if (selectedFiles.length === 0) {
return (
diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx
index 21f3649c5..fd9eabff1 100644
--- a/frontend/src/components/tools/shared/createToolFlow.tsx
+++ b/frontend/src/components/tools/shared/createToolFlow.tsx
@@ -1,5 +1,4 @@
import React from 'react';
-import { Stack } from '@mantine/core';
import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
@@ -57,7 +56,7 @@ export interface ToolFlowConfig {
*/
export function createToolFlow(config: ToolFlowConfig) {
const steps = createToolSteps();
-
+
return (
{/* Files Step */}
@@ -69,7 +68,7 @@ export function createToolFlow(config: ToolFlowConfig) {
})}
{/* Middle Steps */}
- {config.steps.map((stepConfig, index) =>
+ {config.steps.map((stepConfig, index) =>
steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed,
@@ -99,4 +98,4 @@ export function createToolFlow(config: ToolFlowConfig) {
})}
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx
index 953c7720f..20316b1a2 100644
--- a/frontend/src/contexts/FileContext.tsx
+++ b/frontend/src/contexts/FileContext.tsx
@@ -35,6 +35,7 @@ const initialViewerConfig: ViewerConfig = {
const initialState: FileContextState = {
activeFiles: [],
processedFiles: new Map(),
+ pinnedFiles: new Set(),
currentMode: 'pageEditor',
currentView: 'fileEditor', // Legacy field
currentTool: null, // Legacy field
@@ -77,6 +78,9 @@ type FileContextAction =
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean }
+ | { type: 'PIN_FILE'; payload: File }
+ | { type: 'UNPIN_FILE'; payload: File }
+ | { type: 'CONSUME_FILES'; payload: { inputFiles: File[]; outputFiles: File[] } }
| { type: 'RESET_CONTEXT' }
| { type: 'LOAD_STATE'; payload: Partial };
@@ -317,6 +321,43 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
showNavigationWarning: action.payload
};
+ case 'PIN_FILE':
+ return {
+ ...state,
+ pinnedFiles: new Set([...state.pinnedFiles, action.payload])
+ };
+
+ case 'UNPIN_FILE':
+ const newPinnedFiles = new Set(state.pinnedFiles);
+ newPinnedFiles.delete(action.payload);
+ return {
+ ...state,
+ pinnedFiles: newPinnedFiles
+ };
+
+ case 'CONSUME_FILES': {
+ const { inputFiles, outputFiles } = action.payload;
+ const unpinnedInputFiles = inputFiles.filter(file => !state.pinnedFiles.has(file));
+
+ // Remove unpinned input files and add output files
+ const newActiveFiles = [
+ ...state.activeFiles.filter(file => !unpinnedInputFiles.includes(file)),
+ ...outputFiles
+ ];
+
+ // Update processed files map - remove consumed files, keep pinned ones
+ const newProcessedFiles = new Map(state.processedFiles);
+ unpinnedInputFiles.forEach(file => {
+ newProcessedFiles.delete(file);
+ });
+
+ return {
+ ...state,
+ activeFiles: newActiveFiles,
+ processedFiles: newProcessedFiles
+ };
+ }
+
case 'RESET_CONTEXT':
return {
...initialState
@@ -562,6 +603,46 @@ export function FileContextProvider({
dispatch({ type: 'CLEAR_SELECTIONS' });
}, [cleanupAllFiles]);
+ // File pinning functions
+ const pinFile = useCallback((file: File) => {
+ dispatch({ type: 'PIN_FILE', payload: file });
+ }, []);
+
+ const unpinFile = useCallback((file: File) => {
+ dispatch({ type: 'UNPIN_FILE', payload: file });
+ }, []);
+
+ const isFilePinned = useCallback((file: File): boolean => {
+ return state.pinnedFiles.has(file);
+ }, [state.pinnedFiles]);
+
+ // File consumption function
+ const consumeFiles = useCallback(async (inputFiles: File[], outputFiles: File[]): Promise => {
+ dispatch({ type: 'CONSUME_FILES', payload: { inputFiles, outputFiles } });
+
+ // Store new output files if persistence is enabled
+ if (enablePersistence) {
+ for (const file of outputFiles) {
+ try {
+ const fileId = getFileId(file);
+ if (!fileId) {
+ try {
+ const thumbnail = await (thumbnailGenerationService as any).generateThumbnail(file);
+ const storedFile = await fileStorage.storeFile(file, thumbnail);
+ Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
+ } catch (thumbnailError) {
+ console.warn('Failed to generate thumbnail, storing without:', thumbnailError);
+ const storedFile = await fileStorage.storeFile(file);
+ Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
+ }
+ }
+ } catch (error) {
+ console.error('Failed to store output file:', error);
+ }
+ }
+ }
+ }, [enablePersistence, state.pinnedFiles]);
+
// Navigation guard system functions
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
@@ -785,6 +866,10 @@ export function FileContextProvider({
removeFiles,
replaceFile,
clearAllFiles,
+ pinFile,
+ unpinFile,
+ isFilePinned,
+ consumeFiles,
setCurrentMode,
setCurrentView,
setCurrentTool,
diff --git a/frontend/src/contexts/FileSelectionContext.tsx b/frontend/src/contexts/FileSelectionContext.tsx
index 2c79882b2..a0169b7ab 100644
--- a/frontend/src/contexts/FileSelectionContext.tsx
+++ b/frontend/src/contexts/FileSelectionContext.tsx
@@ -1,8 +1,9 @@
-import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
+import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import {
MaxFiles,
FileSelectionContextValue
} from '../types/tool';
+import { useFileContext } from './FileContext';
interface FileSelectionProviderProps {
children: ReactNode;
@@ -11,10 +12,23 @@ interface FileSelectionProviderProps {
const FileSelectionContext = createContext(undefined);
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
+ const { activeFiles } = useFileContext();
const [selectedFiles, setSelectedFiles] = useState([]);
const [maxFiles, setMaxFiles] = useState(-1);
const [isToolMode, setIsToolMode] = useState(false);
+ // Sync selected files with active files - remove any selected files that are no longer active
+ useEffect(() => {
+ if (selectedFiles.length > 0) {
+ const activeFileSet = new Set(activeFiles);
+ const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file));
+
+ if (validSelectedFiles.length !== selectedFiles.length) {
+ setSelectedFiles(validSelectedFiles);
+ }
+ }
+ }, [activeFiles, selectedFiles]);
+
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts
index ebcac8b66..f4781e729 100644
--- a/frontend/src/hooks/tools/compress/useCompressOperation.ts
+++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts
@@ -11,7 +11,7 @@ export interface CompressParameters {
fileSizeUnit: 'KB' | 'MB';
}
-const buildFormData = (parameters: CompressParameters, file: File): FormData => {
+const buildFormData = (file: File, parameters: CompressParameters): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts
index b10aa069d..bdd2d93b2 100644
--- a/frontend/src/hooks/tools/shared/useToolOperation.ts
+++ b/frontend/src/hooks/tools/shared/useToolOperation.ts
@@ -104,7 +104,7 @@ export const useToolOperation = (
config: ToolOperationConfig
): ToolOperationHook => {
const { t } = useTranslation();
- const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
+ const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
@@ -198,8 +198,8 @@ export const useToolOperation = (
actions.setThumbnails(thumbnails);
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
- // Add to file context
- await addFiles(processedFiles);
+ // Consume input files and add output files (will replace unpinned inputs)
+ await consumeFiles(validFiles, processedFiles);
markOperationApplied(fileId, operationId);
}
diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx
index 19253b1eb..b727dbc87 100644
--- a/frontend/src/tools/Compress.tsx
+++ b/frontend/src/tools/Compress.tsx
@@ -29,7 +29,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
useEffect(() => {
compressOperation.resetResults();
onPreviewFile?.(null);
- }, [compressParams.parameters, selectedFiles]);
+ }, [compressParams.parameters]);
const handleCompress = async () => {
try {
@@ -61,7 +61,6 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
- const filesCollapsed = hasFiles;
const settingsCollapsed = !hasFiles || hasResults;
return (
@@ -69,7 +68,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
{createToolFlow({
files: {
selectedFiles,
- isCollapsed: filesCollapsed
+ isCollapsed: hasFiles
},
steps: [{
title: "Settings",
@@ -86,6 +85,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}],
executeButton: {
text: t("compress.submit", "Compress"),
+ isVisible: !hasResults,
loadingText: t("loading"),
onClick: handleCompress,
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled
diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts
index 555abdc4c..7d54c220f 100644
--- a/frontend/src/types/fileContext.ts
+++ b/frontend/src/types/fileContext.ts
@@ -55,6 +55,7 @@ export interface FileContextState {
// Core file management
activeFiles: File[];
processedFiles: Map;
+ pinnedFiles: Set; // Files that are pinned and won't be consumed
// Current navigation state
currentMode: ModeType;
@@ -95,6 +96,14 @@ export interface FileContextActions {
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise;
clearAllFiles: () => void;
+
+ // File pinning
+ pinFile: (file: File) => void;
+ unpinFile: (file: File) => void;
+ isFilePinned: (file: File) => boolean;
+
+ // File consumption (replace unpinned files with outputs)
+ consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise;
// Navigation
setCurrentMode: (mode: ModeType) => void;