mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-03 17:52:30 +02:00
Undo Button -> review state. Result files added to recents (#4337)
Produced PDFs go into recent files Undo button added to review state Undo causes undoConsume which replaces result files with source files. Removes result files from recent files too --------- Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
parent
96aa43860b
commit
1a3e8e7ecf
@ -13,9 +13,10 @@
|
|||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(node:*)",
|
"Bash(node:*)",
|
||||||
"Bash(npm run dev:*)",
|
"Bash(npm run dev:*)",
|
||||||
"Bash(sed:*)"
|
"Bash(sed:*)",
|
||||||
|
"Bash(npm run typecheck:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"defaultMode": "acceptEdits"
|
"defaultMode": "acceptEdits"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -42,6 +42,7 @@
|
|||||||
"prebuild": "npm run generate-icons",
|
"prebuild": "npm run generate-icons",
|
||||||
"build": "npx tsc --noEmit && vite build",
|
"build": "npx tsc --noEmit && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"generate-licenses": "node scripts/generate-licenses.js",
|
"generate-licenses": "node scripts/generate-licenses.js",
|
||||||
"generate-icons": "node scripts/generate-icons.js",
|
"generate-icons": "node scripts/generate-icons.js",
|
||||||
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
||||||
|
@ -41,6 +41,9 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saveToBrowser": "Save to Browser",
|
"saveToBrowser": "Save to Browser",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||||
|
"undo": "Undo",
|
||||||
|
"moreOptions": "More Options",
|
||||||
"editYourNewFiles": "Edit your new file(s)",
|
"editYourNewFiles": "Edit your new file(s)",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"fileSelected": "Selected: {{filename}}",
|
"fileSelected": "Selected: {{filename}}",
|
||||||
@ -2382,4 +2385,4 @@
|
|||||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,48 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Button, Stack, Text } from '@mantine/core';
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import ErrorNotification from './ErrorNotification';
|
import UndoIcon from "@mui/icons-material/Undo";
|
||||||
import ResultsPreview from './ResultsPreview';
|
import ErrorNotification from "./ErrorNotification";
|
||||||
import { SuggestedToolsSection } from './SuggestedToolsSection';
|
import ResultsPreview from "./ResultsPreview";
|
||||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
import { SuggestedToolsSection } from "./SuggestedToolsSection";
|
||||||
|
import { ToolOperationHook } from "../../../hooks/tools/shared/useToolOperation";
|
||||||
|
import { Tooltip } from "../../shared/Tooltip";
|
||||||
|
|
||||||
export interface ReviewToolStepProps<TParams = unknown> {
|
export interface ReviewToolStepProps<TParams = unknown> {
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
operation: ToolOperationHook<TParams>;
|
operation: ToolOperationHook<TParams>;
|
||||||
title?: string;
|
title?: string;
|
||||||
onFileClick?: (file: File) => void;
|
onFileClick?: (file: File) => void;
|
||||||
|
onUndo: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) {
|
function ReviewStepContent<TParams = unknown>({
|
||||||
|
operation,
|
||||||
|
onFileClick,
|
||||||
|
onUndo,
|
||||||
|
}: {
|
||||||
|
operation: ToolOperationHook<TParams>;
|
||||||
|
onFileClick?: (file: File) => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const stepRef = useRef<HTMLDivElement>(null);
|
const stepRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const previewFiles = operation.files?.map((file, index) => ({
|
const handleUndo = async () => {
|
||||||
file,
|
try {
|
||||||
thumbnail: operation.thumbnails[index]
|
onUndo();
|
||||||
})) || [];
|
} catch (error) {
|
||||||
|
// Error is already handled by useToolOperation, just reset loading state
|
||||||
|
console.error("Undo operation failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewFiles =
|
||||||
|
operation.files?.map((file, index) => ({
|
||||||
|
file,
|
||||||
|
thumbnail: operation.thumbnails[index],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
// Auto-scroll to bottom when content appears
|
// Auto-scroll to bottom when content appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -31,7 +52,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollableContainer.scrollTo({
|
scrollableContainer.scrollTo({
|
||||||
top: scrollableContainer.scrollHeight,
|
top: scrollableContainer.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}, 100); // Small delay to ensure content is rendered
|
}, 100); // Small delay to ensure content is rendered
|
||||||
}
|
}
|
||||||
@ -40,10 +61,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm" ref={stepRef}>
|
<Stack gap="sm" ref={stepRef}>
|
||||||
<ErrorNotification
|
<ErrorNotification error={operation.errorMessage} onClose={operation.clearError} />
|
||||||
error={operation.errorMessage}
|
|
||||||
onClose={operation.clearError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{previewFiles.length > 0 && (
|
{previewFiles.length > 0 && (
|
||||||
<ResultsPreview
|
<ResultsPreview
|
||||||
@ -53,7 +71,18 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{operation.downloadUrl && (
|
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
|
||||||
|
<Button
|
||||||
|
leftSection={<UndoIcon />}
|
||||||
|
variant="outline"
|
||||||
|
color="var(--mantine-color-gray-6)"
|
||||||
|
onClick={handleUndo}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t("undo", "Undo")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
{operation.downloadUrl && (
|
||||||
<Button
|
<Button
|
||||||
component="a"
|
component="a"
|
||||||
href={operation.downloadUrl}
|
href={operation.downloadUrl}
|
||||||
@ -78,14 +107,13 @@ export function createReviewToolStep<TParams = unknown>(
|
|||||||
): React.ReactElement {
|
): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return createStep(t("review", "Review"), {
|
return createStep(
|
||||||
isVisible: props.isVisible,
|
t("review", "Review"),
|
||||||
_excludeFromCount: true,
|
{
|
||||||
_noPadding: true
|
isVisible: props.isVisible,
|
||||||
}, (
|
_excludeFromCount: true,
|
||||||
<ReviewStepContent
|
_noPadding: true,
|
||||||
operation={props.operation}
|
},
|
||||||
onFileClick={props.onFileClick}
|
<ReviewStepContent operation={props.operation} onFileClick={props.onFileClick} onUndo={props.onUndo} />
|
||||||
/>
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,7 @@ export interface ReviewStepConfig {
|
|||||||
operation: ToolOperationHook<any>;
|
operation: ToolOperationHook<any>;
|
||||||
title: string;
|
title: string;
|
||||||
onFileClick?: (file: File) => void;
|
onFileClick?: (file: File) => void;
|
||||||
|
onUndo: () => void;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,7 +106,8 @@ export function createToolFlow(config: ToolFlowConfig) {
|
|||||||
isVisible: config.review.isVisible,
|
isVisible: config.review.isVisible,
|
||||||
operation: config.review.operation,
|
operation: config.review.operation,
|
||||||
title: config.review.title,
|
title: config.review.title,
|
||||||
onFileClick: config.review.onFileClick
|
onFileClick: config.review.onFileClick,
|
||||||
|
onUndo: config.review.onUndo
|
||||||
})}
|
})}
|
||||||
</ToolStepProvider>
|
</ToolStepProvider>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -25,7 +25,7 @@ import {
|
|||||||
// Import modular components
|
// Import modular components
|
||||||
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
||||||
import { createFileSelectors } from './file/fileSelectors';
|
import { createFileSelectors } from './file/fileSelectors';
|
||||||
import { AddedFile, addFiles, consumeFiles, createFileActions } from './file/fileActions';
|
import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||||
import { FileLifecycleManager } from './file/lifecycle';
|
import { FileLifecycleManager } from './file/lifecycle';
|
||||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||||
@ -121,9 +121,13 @@ function FileContextInner({
|
|||||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||||
|
|
||||||
// Helper functions for pinned files
|
// Helper functions for pinned files
|
||||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<void> => {
|
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
|
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
|
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
|
||||||
|
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||||
|
}, [indexedDB]);
|
||||||
|
|
||||||
// Helper to find FileId from File object
|
// Helper to find FileId from File object
|
||||||
const findFileId = useCallback((file: File): FileId | undefined => {
|
const findFileId = useCallback((file: File): FileId | undefined => {
|
||||||
@ -206,6 +210,7 @@ function FileContextInner({
|
|||||||
pinFile: pinFileWrapper,
|
pinFile: pinFileWrapper,
|
||||||
unpinFile: unpinFileWrapper,
|
unpinFile: unpinFileWrapper,
|
||||||
consumeFiles: consumeFilesWrapper,
|
consumeFiles: consumeFilesWrapper,
|
||||||
|
undoConsumeFiles: undoConsumeFilesWrapper,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
||||||
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
|
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||||
@ -219,6 +224,7 @@ function FileContextInner({
|
|||||||
lifecycleManager,
|
lifecycleManager,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
consumeFilesWrapper,
|
consumeFilesWrapper,
|
||||||
|
undoConsumeFilesWrapper,
|
||||||
pinFileWrapper,
|
pinFileWrapper,
|
||||||
unpinFileWrapper,
|
unpinFileWrapper,
|
||||||
indexedDB,
|
indexedDB,
|
||||||
|
@ -25,6 +25,47 @@ export const initialFileContextState: FileContextState = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function for consume/undo operations
|
||||||
|
function processFileSwap(
|
||||||
|
state: FileContextState,
|
||||||
|
filesToRemove: FileId[],
|
||||||
|
filesToAdd: FileRecord[]
|
||||||
|
): FileContextState {
|
||||||
|
// Only remove unpinned files
|
||||||
|
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||||
|
const remainingIds = state.files.ids.filter(id => !unpinnedRemoveIds.includes(id));
|
||||||
|
|
||||||
|
// Remove unpinned files from state
|
||||||
|
const newById = { ...state.files.byId };
|
||||||
|
unpinnedRemoveIds.forEach(id => {
|
||||||
|
delete newById[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add new files
|
||||||
|
const addedIds: FileId[] = [];
|
||||||
|
filesToAdd.forEach(record => {
|
||||||
|
if (!newById[record.id]) {
|
||||||
|
addedIds.push(record.id);
|
||||||
|
newById[record.id] = record;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear selections that reference removed files
|
||||||
|
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: {
|
||||||
|
ids: [...addedIds, ...remainingIds],
|
||||||
|
byId: newById
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
selectedFileIds: validSelectedFileIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Pure reducer function
|
// Pure reducer function
|
||||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
@ -193,40 +234,12 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
|||||||
|
|
||||||
case 'CONSUME_FILES': {
|
case 'CONSUME_FILES': {
|
||||||
const { inputFileIds, outputFileRecords } = action.payload;
|
const { inputFileIds, outputFileRecords } = action.payload;
|
||||||
|
return processFileSwap(state, inputFileIds, outputFileRecords);
|
||||||
|
}
|
||||||
|
|
||||||
// Only remove unpinned input files
|
case 'UNDO_CONSUME_FILES': {
|
||||||
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
|
const { inputFileRecords, outputFileIds } = action.payload;
|
||||||
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
|
return processFileSwap(state, outputFileIds, inputFileRecords);
|
||||||
|
|
||||||
// Remove unpinned files from state
|
|
||||||
const newById = { ...state.files.byId };
|
|
||||||
unpinnedInputIds.forEach(id => {
|
|
||||||
delete newById[id];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add output files
|
|
||||||
const outputIds: FileId[] = [];
|
|
||||||
outputFileRecords.forEach(record => {
|
|
||||||
if (!newById[record.id]) {
|
|
||||||
outputIds.push(record.id);
|
|
||||||
newById[record.id] = record;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selections that reference removed files
|
|
||||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
files: {
|
|
||||||
ids: [...remainingIds, ...outputIds],
|
|
||||||
byId: newById
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
...state.ui,
|
|
||||||
selectedFileIds: validSelectedFileIds
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'RESET_CONTEXT': {
|
case 'RESET_CONTEXT': {
|
||||||
|
@ -323,34 +323,28 @@ export async function addFiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consume files helper - replace unpinned input files with output files
|
* Helper function to process files into records with thumbnails and metadata
|
||||||
*/
|
*/
|
||||||
export async function consumeFiles(
|
async function processFilesIntoRecords(
|
||||||
inputFileIds: FileId[],
|
files: File[],
|
||||||
outputFiles: File[],
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
stateRef: React.MutableRefObject<FileContextState>,
|
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
return Promise.all(
|
||||||
dispatch: React.Dispatch<FileContextAction>
|
files.map(async (file) => {
|
||||||
): Promise<void> {
|
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
|
||||||
|
|
||||||
// Process output files through the 'processed' path to generate thumbnails
|
|
||||||
const outputFileRecords = await Promise.all(
|
|
||||||
outputFiles.map(async (file) => {
|
|
||||||
const fileId = createFileId();
|
const fileId = createFileId();
|
||||||
filesRef.current.set(fileId, file);
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
// Generate thumbnail and page count for output file
|
// Generate thumbnail and page count
|
||||||
let thumbnail: string | undefined;
|
let thumbnail: string | undefined;
|
||||||
let pageCount: number = 1;
|
let pageCount: number = 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
|
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
|
||||||
const result = await generateThumbnailWithMetadata(file);
|
const result = await generateThumbnailWithMetadata(file);
|
||||||
thumbnail = result.thumbnail;
|
thumbnail = result.thumbnail;
|
||||||
pageCount = result.pageCount;
|
pageCount = result.pageCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
|
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = toFileRecord(file, fileId);
|
const record = toFileRecord(file, fileId);
|
||||||
@ -362,20 +356,168 @@ export async function consumeFiles(
|
|||||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
return record;
|
return { record, file, fileId, thumbnail };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to persist files to IndexedDB
|
||||||
|
*/
|
||||||
|
async function persistFilesToIndexedDB(
|
||||||
|
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||||
|
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
|
||||||
|
try {
|
||||||
|
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume files helper - replace unpinned input files with output files
|
||||||
|
*/
|
||||||
|
export async function consumeFiles(
|
||||||
|
inputFileIds: FileId[],
|
||||||
|
outputFiles: File[],
|
||||||
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
||||||
|
): Promise<FileId[]> {
|
||||||
|
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||||
|
|
||||||
|
// Process output files with thumbnails and metadata
|
||||||
|
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
|
||||||
|
|
||||||
|
// Persist output files to IndexedDB if available
|
||||||
|
if (indexedDB) {
|
||||||
|
await persistFilesToIndexedDB(outputFileRecords, indexedDB);
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch the consume action
|
// Dispatch the consume action
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'CONSUME_FILES',
|
type: 'CONSUME_FILES',
|
||||||
payload: {
|
payload: {
|
||||||
inputFileIds,
|
inputFileIds,
|
||||||
outputFileRecords
|
outputFileRecords: outputFileRecords.map(({ record }) => record)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||||
|
|
||||||
|
// Return the output file IDs for undo tracking
|
||||||
|
return outputFileRecords.map(({ fileId }) => fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||||
|
*/
|
||||||
|
async function restoreFilesAndCleanup(
|
||||||
|
filesToRestore: Array<{ file: File; record: FileRecord }>,
|
||||||
|
fileIdsToRemove: FileId[],
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
|
): Promise<void> {
|
||||||
|
// Remove files from filesRef
|
||||||
|
fileIdsToRemove.forEach(id => {
|
||||||
|
if (filesRef.current.has(id)) {
|
||||||
|
if (DEBUG) console.log(`📄 Removing file ${id} from filesRef`);
|
||||||
|
filesRef.current.delete(id);
|
||||||
|
} else {
|
||||||
|
if (DEBUG) console.warn(`📄 File ${id} not found in filesRef`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore files to filesRef
|
||||||
|
filesToRestore.forEach(({ file, record }) => {
|
||||||
|
if (file && record) {
|
||||||
|
// Validate the file before restoring
|
||||||
|
if (file.size === 0) {
|
||||||
|
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the file to filesRef
|
||||||
|
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
||||||
|
filesRef.current.set(record.id, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up IndexedDB
|
||||||
|
if (indexedDB) {
|
||||||
|
const indexedDBPromises = fileIdsToRemove.map(fileId =>
|
||||||
|
indexedDB.deleteFile(fileId).catch(error => {
|
||||||
|
console.error('Failed to delete file from IndexedDB:', fileId, error);
|
||||||
|
throw error; // Re-throw to trigger rollback
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute all IndexedDB operations
|
||||||
|
await Promise.all(indexedDBPromises);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undoes a previous consumeFiles operation by restoring input files and removing output files (unless pinned)
|
||||||
|
*/
|
||||||
|
export async function undoConsumeFiles(
|
||||||
|
inputFiles: File[],
|
||||||
|
inputFileRecords: FileRecord[],
|
||||||
|
outputFileIds: FileId[],
|
||||||
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
|
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (inputFiles.length !== inputFileRecords.length) {
|
||||||
|
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a backup of current filesRef state for rollback
|
||||||
|
const backupFilesRef = new Map(filesRef.current);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare files to restore
|
||||||
|
const filesToRestore = inputFiles.map((file, index) => ({
|
||||||
|
file,
|
||||||
|
record: inputFileRecords[index]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Restore input files and clean up output files
|
||||||
|
await restoreFilesAndCleanup(
|
||||||
|
filesToRestore,
|
||||||
|
outputFileIds,
|
||||||
|
filesRef,
|
||||||
|
indexedDB
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch the undo action (only if everything else succeeded)
|
||||||
|
dispatch({
|
||||||
|
type: 'UNDO_CONSUME_FILES',
|
||||||
|
payload: {
|
||||||
|
inputFileRecords,
|
||||||
|
outputFileIds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback filesRef to previous state
|
||||||
|
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||||
|
filesRef.current.clear();
|
||||||
|
backupFilesRef.forEach((file, id) => {
|
||||||
|
filesRef.current.set(id, file);
|
||||||
|
});
|
||||||
|
throw error; // Re-throw to let caller handle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -165,6 +165,7 @@ export function useFileContext() {
|
|||||||
// File management
|
// File management
|
||||||
addFiles: actions.addFiles,
|
addFiles: actions.addFiles,
|
||||||
consumeFiles: actions.consumeFiles,
|
consumeFiles: actions.consumeFiles,
|
||||||
|
undoConsumeFiles: actions.undoConsumeFiles,
|
||||||
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
|
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
|
||||||
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
|
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
|
||||||
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
|
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
|
||||||
@ -187,7 +188,11 @@ export function useFileContext() {
|
|||||||
isFilePinned: selectors.isFilePinned,
|
isFilePinned: selectors.isFilePinned,
|
||||||
|
|
||||||
// Active files
|
// Active files
|
||||||
activeFiles: selectors.getFiles()
|
activeFiles: selectors.getFiles(),
|
||||||
|
|
||||||
|
// Direct access to actions and selectors (for advanced use cases)
|
||||||
|
actions,
|
||||||
|
selectors
|
||||||
}), [state, selectors, actions]);
|
}), [state, selectors, actions]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,6 +46,7 @@ describe('useAddPasswordOperation', () => {
|
|||||||
resetResults: vi.fn(),
|
resetResults: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
cancelOperation: vi.fn(),
|
cancelOperation: vi.fn(),
|
||||||
|
undoOperation: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -45,6 +45,7 @@ describe('useChangePermissionsOperation', () => {
|
|||||||
resetResults: vi.fn(),
|
resetResults: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
cancelOperation: vi.fn(),
|
cancelOperation: vi.fn(),
|
||||||
|
undoOperation: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -44,6 +44,7 @@ describe('useRemovePasswordOperation', () => {
|
|||||||
resetResults: vi.fn(),
|
resetResults: vi.fn(),
|
||||||
clearError: vi.fn(),
|
clearError: vi.fn(),
|
||||||
cancelOperation: vi.fn(),
|
cancelOperation: vi.fn(),
|
||||||
|
undoOperation: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -21,6 +21,7 @@ interface BaseToolReturn<TParams> {
|
|||||||
handleExecute: () => Promise<void>;
|
handleExecute: () => Promise<void>;
|
||||||
handleThumbnailClick: (file: File) => void;
|
handleThumbnailClick: (file: File) => void;
|
||||||
handleSettingsReset: () => void;
|
handleSettingsReset: () => void;
|
||||||
|
handleUndo: () => Promise<void>;
|
||||||
|
|
||||||
// Standard computed state
|
// Standard computed state
|
||||||
hasFiles: boolean;
|
hasFiles: boolean;
|
||||||
@ -88,6 +89,11 @@ export function useBaseTool<TParams>(
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [operation, onPreviewFile]);
|
}, [operation, onPreviewFile]);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(async () => {
|
||||||
|
await operation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
}, [operation, onPreviewFile]);
|
||||||
|
|
||||||
// Standard computed state
|
// Standard computed state
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||||
@ -109,6 +115,7 @@ export function useBaseTool<TParams>(
|
|||||||
handleExecute,
|
handleExecute,
|
||||||
handleThumbnailClick,
|
handleThumbnailClick,
|
||||||
handleSettingsReset,
|
handleSettingsReset,
|
||||||
|
handleUndo,
|
||||||
|
|
||||||
// State
|
// State
|
||||||
hasFiles,
|
hasFiles,
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useRef, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFileContext } from '../../../contexts/FileContext';
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
@ -9,6 +9,7 @@ import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
|||||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
import { createOperation } from '../../../utils/toolOperationTracker';
|
||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
import { FileId } from '../../../types/file';
|
import { FileId } from '../../../types/file';
|
||||||
|
import { FileRecord } from '../../../types/fileContext';
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type { ProcessingProgress, ResponseHandler };
|
export type { ProcessingProgress, ResponseHandler };
|
||||||
@ -107,6 +108,7 @@ export interface ToolOperationHook<TParams = void> {
|
|||||||
resetResults: () => void;
|
resetResults: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
cancelOperation: () => void;
|
cancelOperation: () => void;
|
||||||
|
undoOperation: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
@ -128,13 +130,20 @@ export const useToolOperation = <TParams>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext();
|
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||||
|
|
||||||
|
// Track last operation for undo functionality
|
||||||
|
const lastOperationRef = useRef<{
|
||||||
|
inputFiles: File[];
|
||||||
|
inputFileRecords: FileRecord[];
|
||||||
|
outputFileIds: FileId[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const executeOperation = useCallback(async (
|
const executeOperation = useCallback(async (
|
||||||
params: TParams,
|
params: TParams,
|
||||||
selectedFiles: File[]
|
selectedFiles: File[]
|
||||||
@ -232,8 +241,33 @@ export const useToolOperation = <TParams>(
|
|||||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||||
|
|
||||||
// Replace input files with processed files (consumeFiles handles pinning)
|
// Replace input files with processed files (consumeFiles handles pinning)
|
||||||
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as FileId[];
|
const inputFileIds: FileId[] = [];
|
||||||
await consumeFiles(inputFileIds, processedFiles);
|
const inputFileRecords: FileRecord[] = [];
|
||||||
|
|
||||||
|
// Build parallel arrays of IDs and records for undo tracking
|
||||||
|
for (const file of validFiles) {
|
||||||
|
const fileId = findFileId(file);
|
||||||
|
if (fileId) {
|
||||||
|
const record = selectors.getFileRecord(fileId);
|
||||||
|
if (record) {
|
||||||
|
inputFileIds.push(fileId);
|
||||||
|
inputFileRecords.push(record);
|
||||||
|
} else {
|
||||||
|
console.warn(`No file record found for file: ${file.name}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`No file ID found for file: ${file.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
|
||||||
|
|
||||||
|
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||||
|
lastOperationRef.current = {
|
||||||
|
inputFiles: validFiles, // Keep original File objects for undo
|
||||||
|
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||||
|
outputFileIds
|
||||||
|
};
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
markOperationApplied(fileId, operationId);
|
||||||
}
|
}
|
||||||
@ -259,8 +293,65 @@ export const useToolOperation = <TParams>(
|
|||||||
const resetResults = useCallback(() => {
|
const resetResults = useCallback(() => {
|
||||||
cleanupBlobUrls();
|
cleanupBlobUrls();
|
||||||
actions.resetResults();
|
actions.resetResults();
|
||||||
|
// Clear undo data when results are reset to prevent memory leaks
|
||||||
|
lastOperationRef.current = null;
|
||||||
}, [cleanupBlobUrls, actions]);
|
}, [cleanupBlobUrls, actions]);
|
||||||
|
|
||||||
|
// Cleanup on unmount to prevent memory leaks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
lastOperationRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undoOperation = useCallback(async () => {
|
||||||
|
if (!lastOperationRef.current) {
|
||||||
|
actions.setError(t('noOperationToUndo', 'No operation to undo'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
|
||||||
|
|
||||||
|
// Validate that we have data to undo
|
||||||
|
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
|
||||||
|
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outputFileIds.length === 0) {
|
||||||
|
actions.setError(t('noFilesToUndo', 'Cannot undo: no files were processed in the last operation'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Undo the consume operation
|
||||||
|
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
|
||||||
|
|
||||||
|
// Clear results and operation tracking
|
||||||
|
resetResults();
|
||||||
|
lastOperationRef.current = null;
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
let errorMessage = extractErrorMessage(error);
|
||||||
|
|
||||||
|
// Provide more specific error messages based on error type
|
||||||
|
if (error.message?.includes('Mismatch between input files')) {
|
||||||
|
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
|
||||||
|
} else if (error.message?.includes('IndexedDB')) {
|
||||||
|
errorMessage = t('undoStorageError', 'Undo completed but some files could not be saved to storage');
|
||||||
|
} else if (error.name === 'QuotaExceededError') {
|
||||||
|
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
|
||||||
|
|
||||||
|
// Don't clear the operation data if undo failed - user might want to try again
|
||||||
|
}
|
||||||
|
}, [undoConsumeFiles, resetResults, actions, t]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
files: state.files,
|
files: state.files,
|
||||||
@ -277,6 +368,7 @@ export const useToolOperation = <TParams>(
|
|||||||
executeOperation,
|
executeOperation,
|
||||||
resetResults,
|
resetResults,
|
||||||
clearError: actions.clearError,
|
clearError: actions.clearError,
|
||||||
cancelOperation
|
cancelOperation,
|
||||||
|
undoOperation
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -60,6 +60,11 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await addPasswordOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
|
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
|
||||||
const passwordsCollapsed = !hasFiles || hasResults;
|
const passwordsCollapsed = !hasFiles || hasResults;
|
||||||
@ -110,6 +115,7 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
operation: addPasswordOperation,
|
operation: addPasswordOperation,
|
||||||
title: t("addPassword.results.title", "Encrypted PDFs"),
|
title: t("addPassword.results.title", "Encrypted PDFs"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -79,6 +79,11 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await watermarkOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
const hasResults = watermarkOperation.files.length > 0 || watermarkOperation.downloadUrl !== null;
|
const hasResults = watermarkOperation.files.length > 0 || watermarkOperation.downloadUrl !== null;
|
||||||
|
|
||||||
@ -203,6 +208,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
operation: watermarkOperation,
|
operation: watermarkOperation,
|
||||||
title: t("watermark.results.title", "Watermark Results"),
|
title: t("watermark.results.title", "Watermark Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
},
|
},
|
||||||
forceStepNumbers: true,
|
forceStepNumbers: true,
|
||||||
});
|
});
|
||||||
|
@ -43,6 +43,11 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await automateOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Register reset function with the tool workflow context - only once on mount
|
// Register reset function with the tool workflow context - only once on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const stableResetFunction = () => {
|
const stableResetFunction = () => {
|
||||||
@ -224,7 +229,8 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onFileClick: (file: File) => {
|
onFileClick: (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
actions.setWorkbench('viewer');
|
actions.setWorkbench('viewer');
|
||||||
}
|
},
|
||||||
|
onUndo: handleUndo
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -50,6 +50,7 @@ const ChangePermissions = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("changePermissions.results.title", "Modified PDFs"),
|
title: t("changePermissions.results.title", "Modified PDFs"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -50,6 +50,7 @@ const Compress = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("compress.title", "Compression Results"),
|
title: t("compress.title", "Compression Results"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -93,6 +93,11 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await convertOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
files: {
|
files: {
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
@ -128,6 +133,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
operation: convertOperation,
|
operation: convertOperation,
|
||||||
title: t("convert.conversionResults", "Conversion Results"),
|
title: t("convert.conversionResults", "Conversion Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
testId: "conversion-results",
|
testId: "conversion-results",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -74,6 +74,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUndo = async () => {
|
||||||
|
await ocrOperation.undoOperation();
|
||||||
|
onPreviewFile?.(null);
|
||||||
|
};
|
||||||
|
|
||||||
const settingsCollapsed = expandedStep !== "settings";
|
const settingsCollapsed = expandedStep !== "settings";
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
@ -132,6 +137,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
operation: ocrOperation,
|
operation: ocrOperation,
|
||||||
title: t("ocr.results.title", "OCR Results"),
|
title: t("ocr.results.title", "OCR Results"),
|
||||||
onFileClick: handleThumbnailClick,
|
onFileClick: handleThumbnailClick,
|
||||||
|
onUndo: handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -34,6 +34,7 @@ const RemoveCertificateSign = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("removeCertSign.results.title", "Certificate Removal Results"),
|
title: t("removeCertSign.results.title", "Certificate Removal Results"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -50,6 +50,7 @@ const RemovePassword = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("removePassword.results.title", "Decrypted PDFs"),
|
title: t("removePassword.results.title", "Decrypted PDFs"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -34,6 +34,7 @@ const Repair = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("repair.results.title", "Repair Results"),
|
title: t("repair.results.title", "Repair Results"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -48,6 +48,7 @@ const Sanitize = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("sanitize.sanitizationResults", "Sanitization Results"),
|
title: t("sanitize.sanitizationResults", "Sanitization Results"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -34,6 +34,7 @@ const SingleLargePage = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("pdfToSinglePage.results.title", "Single Page Results"),
|
title: t("pdfToSinglePage.results.title", "Single Page Results"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -48,6 +48,7 @@ const Split = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: "Split Results",
|
title: "Split Results",
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -34,6 +34,7 @@ const UnlockPdfForms = (props: BaseToolProps) => {
|
|||||||
operation: base.operation,
|
operation: base.operation,
|
||||||
title: t("unlockPDFForms.results.title", "Unlocked Forms Results"),
|
title: t("unlockPDFForms.results.title", "Unlocked Forms Results"),
|
||||||
onFileClick: base.handleThumbnailClick,
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -199,6 +199,7 @@ export type FileContextAction =
|
|||||||
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
||||||
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
||||||
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
|
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
|
||||||
|
| { type: 'UNDO_CONSUME_FILES'; payload: { inputFileRecords: FileRecord[]; outputFileIds: FileId[] } }
|
||||||
|
|
||||||
// UI actions
|
// UI actions
|
||||||
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
||||||
@ -228,7 +229,8 @@ export interface FileContextActions {
|
|||||||
unpinFile: (file: File) => void;
|
unpinFile: (file: File) => void;
|
||||||
|
|
||||||
// File consumption (replace unpinned files with outputs)
|
// File consumption (replace unpinned files with outputs)
|
||||||
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<void>;
|
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<FileId[]>;
|
||||||
|
undoConsumeFiles: (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]) => Promise<void>;
|
||||||
// Selection management
|
// Selection management
|
||||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||||
setSelectedPages: (pageNumbers: number[]) => void;
|
setSelectedPages: (pageNumbers: number[]) => void;
|
||||||
|
Loading…
Reference in New Issue
Block a user