mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Feature/v2/multiselect (#4024)
# Description of Changes This pull request introduces significant updates to the file selection logic, tool rendering, and file context management in the frontend codebase. The changes aim to improve modularity, enhance maintainability, and streamline the handling of file-related operations. Key updates include the introduction of a new `FileSelectionContext`, refactoring of file selection logic, and updates to tool management and rendering. ### File Selection Context and Logic Refactor: * Added a new `FileSelectionContext` to centralize file selection state and provide utility hooks for managing selected files, selection limits, and tool mode. (`frontend/src/contexts/FileSelectionContext.tsx`, [frontend/src/contexts/FileSelectionContext.tsxR1-R77](diffhunk://#diff-bda35f1aaa5eafa0a0dc48e0b1270d862f6da360ba1241234e891f0ca8907327R1-R77)) * Replaced local file selection logic in `FileEditor` with context-based logic, improving consistency and reducing duplication. (`frontend/src/components/fileEditor/FileEditor.tsx`, [[1]](diffhunk://#diff-481d0a2d8a1714d34d21181db63a020b08dfccfbfa80bf47ac9af382dff25310R63-R70) [[2]](diffhunk://#diff-481d0a2d8a1714d34d21181db63a020b08dfccfbfa80bf47ac9af382dff25310R404-R438) ### Tool Management and Rendering: * Refactored `ToolRenderer` to use a `Suspense` fallback for lazy-loaded tools, improving user experience during tool loading. (`frontend/src/components/tools/ToolRenderer.tsx`, [frontend/src/components/tools/ToolRenderer.tsxL32-L64](diffhunk://#diff-2083701113aa92cd1f5ce1b4b52cc233858e31ed7bcf39c5bfb1bcc34e99b6a9L32-L64)) * Simplified `ToolPicker` by reusing the `ToolRegistry` type, reducing redundancy. (`frontend/src/components/tools/ToolPicker.tsx`, [frontend/src/components/tools/ToolPicker.tsxL4-R4](diffhunk://#diff-e47deca9132018344c159925f1264794acdd57f4b65e582eb9b2a4ea69ec126dL4-R4)) ### File Context Enhancements: * Introduced a utility function `getFileId` for consistent file ID extraction, replacing repetitive inline logic. (`frontend/src/contexts/FileContext.tsx`, [[1]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcR25) [[2]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL101-R102) * Updated `FileContextProvider` to use more specific types for PDF documents, enhancing type safety. (`frontend/src/contexts/FileContext.tsx`, [[1]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL350-R351) [[2]](diffhunk://#diff-95b3d103fa434f81fdae55f2ea14eda705f0def45a0f2c5754f81de6f2fd93bcL384-R385) ### Compression Tool Enhancements: * Added blob URL cleanup logic to the compression hook to prevent memory leaks. (`frontend/src/hooks/tools/compress/useCompressOperation.ts`, [frontend/src/hooks/tools/compress/useCompressOperation.tsR58-L66](diffhunk://#diff-d7815fea0e89989511ae1786f7031cba492b9f2db39b7ade92d9736d1bd4b673R58-L66)) * Adjusted file ID generation in the compression operation to handle multiple files more effectively. (`frontend/src/hooks/tools/compress/useCompressOperation.ts`, [frontend/src/hooks/tools/compress/useCompressOperation.tsL90-R102](diffhunk://#diff-d7815fea0e89989511ae1786f7031cba492b9f2db39b7ade92d9736d1bd4b673L90-R102)) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
This commit is contained in:
@@ -20,7 +20,7 @@ export interface CompressOperationHook {
|
||||
parameters: CompressParameters,
|
||||
selectedFiles: File[]
|
||||
) => Promise<void>;
|
||||
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files: File[];
|
||||
thumbnails: string[];
|
||||
@@ -30,7 +30,7 @@ export interface CompressOperationHook {
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
|
||||
|
||||
// Result management functions
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
@@ -38,13 +38,13 @@ export interface CompressOperationHook {
|
||||
|
||||
export const useCompressOperation = (): CompressOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
const {
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
markOperationFailed,
|
||||
addFiles
|
||||
} = useFileContext();
|
||||
|
||||
|
||||
// Internal state management
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
@@ -55,15 +55,27 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const [blobUrls, setBlobUrls] = useState<string[]>([]);
|
||||
|
||||
const cleanupBlobUrls = useCallback(() => {
|
||||
blobUrls.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.warn('Failed to revoke blob URL:', error);
|
||||
}
|
||||
});
|
||||
setBlobUrls([]);
|
||||
}, [blobUrls]);
|
||||
|
||||
const buildFormData = useCallback((
|
||||
parameters: CompressParameters,
|
||||
selectedFiles: File[]
|
||||
file: File
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
|
||||
formData.append("fileInput", file);
|
||||
|
||||
if (parameters.compressionMethod === 'quality') {
|
||||
formData.append("optimizeLevel", parameters.compressionLevel.toString());
|
||||
@@ -74,7 +86,7 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
formData.append("expectedOutputSize", fileSize);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
formData.append("grayscale", parameters.grayscale.toString());
|
||||
|
||||
const endpoint = "/api/v1/misc/compress-pdf";
|
||||
@@ -87,7 +99,7 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||
const operationId = `compress-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const fileId = selectedFiles[0].name;
|
||||
const fileId = selectedFiles.map(f => f.name).join(',');
|
||||
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
@@ -96,74 +108,20 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
fileIds: selectedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: selectedFiles[0].name,
|
||||
originalFileNames: selectedFiles.map(f => f.name),
|
||||
parameters: {
|
||||
compressionLevel: parameters.compressionLevel,
|
||||
grayscale: parameters.grayscale,
|
||||
expectedSize: parameters.expectedSize,
|
||||
},
|
||||
fileSize: selectedFiles[0].size
|
||||
totalFileSize: selectedFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
fileCount: selectedFiles.length
|
||||
}
|
||||
};
|
||||
|
||||
return { operation, operationId, fileId };
|
||||
}, []);
|
||||
|
||||
const processResults = useCallback(async (blob: Blob, selectedFiles: File[]) => {
|
||||
try {
|
||||
// Check if the response is a PDF file directly or a ZIP file
|
||||
const contentType = blob.type;
|
||||
console.log('Response content type:', contentType);
|
||||
|
||||
if (contentType === 'application/pdf') {
|
||||
// Direct PDF response
|
||||
const originalFileName = selectedFiles[0].name;
|
||||
const pdfFile = new File([blob], `compressed_${originalFileName}`, { type: "application/pdf" });
|
||||
setFiles([pdfFile]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
// Add file to FileContext
|
||||
await addFiles([pdfFile]);
|
||||
|
||||
// Generate thumbnail
|
||||
const thumbnail = await generateThumbnailForFile(pdfFile);
|
||||
setThumbnails([thumbnail || '']);
|
||||
setIsGeneratingThumbnails(false);
|
||||
} else {
|
||||
// ZIP file response (like split operation)
|
||||
const zipFile = new File([blob], "compress_result.zip", { type: "application/zip" });
|
||||
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
||||
|
||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
||||
// Set local state for preview
|
||||
setFiles(extractionResult.extractedFiles);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
// Add extracted files to FileContext for future use
|
||||
await addFiles(extractionResult.extractedFiles);
|
||||
|
||||
const thumbnails = await Promise.all(
|
||||
extractionResult.extractedFiles.map(async (file) => {
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
return thumbnail || '';
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||
return '';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setThumbnails(thumbnails);
|
||||
setIsGeneratingThumbnails(false);
|
||||
}
|
||||
}
|
||||
} catch (extractError) {
|
||||
console.warn('Failed to process results:', extractError);
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
parameters: CompressParameters,
|
||||
@@ -173,32 +131,93 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
setStatus(t("noFileSelected"));
|
||||
return;
|
||||
}
|
||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
||||
if (validFiles.length === 0) {
|
||||
setErrorMessage('No valid files to compress. All selected files are empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (validFiles.length < selectedFiles.length) {
|
||||
console.warn(`Skipping ${selectedFiles.length - validFiles.length} empty files`);
|
||||
}
|
||||
|
||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
||||
const { formData, endpoint } = buildFormData(parameters, selectedFiles);
|
||||
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
setStatus(t("loading"));
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
||||
|
||||
// Determine the correct content type from the response
|
||||
const contentType = response.headers['content-type'] || 'application/zip';
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Generate dynamic filename based on original file and content type
|
||||
const originalFileName = selectedFiles[0].name;
|
||||
const filename = `compressed_${originalFileName}`;
|
||||
setDownloadFilename(filename);
|
||||
setDownloadUrl(url);
|
||||
setStatus(t("downloadComplete"));
|
||||
const compressedFiles: File[] = [];
|
||||
|
||||
await processResults(blob, selectedFiles);
|
||||
const failedFiles: string[] = [];
|
||||
|
||||
for (let i = 0; i < validFiles.length; i++) {
|
||||
const file = validFiles[i];
|
||||
setStatus(`Compressing ${file.name} (${i + 1}/${validFiles.length})`);
|
||||
|
||||
try {
|
||||
const { formData, endpoint } = buildFormData(parameters, file);
|
||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
||||
|
||||
const contentType = response.headers['content-type'] || 'application/pdf';
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const compressedFile = new File([blob], `compressed_${file.name}`, { type: contentType });
|
||||
|
||||
compressedFiles.push(compressedFile);
|
||||
} catch (fileError) {
|
||||
console.error(`Failed to compress ${file.name}:`, fileError);
|
||||
failedFiles.push(file.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0 && compressedFiles.length === 0) {
|
||||
throw new Error(`Failed to compress all files: ${failedFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
setStatus(`Compressed ${compressedFiles.length}/${validFiles.length} files. Failed: ${failedFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
setFiles(compressedFiles);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
await addFiles(compressedFiles);
|
||||
|
||||
cleanupBlobUrls();
|
||||
|
||||
if (compressedFiles.length === 1) {
|
||||
const url = window.URL.createObjectURL(compressedFiles[0]);
|
||||
setDownloadUrl(url);
|
||||
setBlobUrls([url]);
|
||||
setDownloadFilename(`compressed_${selectedFiles[0].name}`);
|
||||
} else {
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(compressedFiles, 'compressed_files.zip');
|
||||
const url = window.URL.createObjectURL(zipFile);
|
||||
setDownloadUrl(url);
|
||||
setBlobUrls([url]);
|
||||
setDownloadFilename(`compressed_${validFiles.length}_files.zip`);
|
||||
}
|
||||
|
||||
const thumbnails = await Promise.all(
|
||||
compressedFiles.map(async (file) => {
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
return thumbnail || '';
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||
return '';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setThumbnails(thumbnails);
|
||||
setIsGeneratingThumbnails(false);
|
||||
setStatus(t("downloadComplete"));
|
||||
markOperationApplied(fileId, operationId);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@@ -214,9 +233,10 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
|
||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, addFiles]);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
cleanupBlobUrls();
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(false);
|
||||
@@ -224,7 +244,7 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
}, [cleanupBlobUrls]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
@@ -232,8 +252,6 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
|
||||
return {
|
||||
executeOperation,
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files,
|
||||
thumbnails,
|
||||
isGeneratingThumbnails,
|
||||
@@ -242,9 +260,9 @@ export const useCompressOperation = (): CompressOperationHook => {
|
||||
status,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
|
||||
|
||||
// Result management functions
|
||||
resetResults,
|
||||
clearError,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,64 +1,75 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
import CompressPdfPanel from "../tools/Compress";
|
||||
import MergePdfPanel from "../tools/Merge";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
|
||||
|
||||
type ToolRegistryEntry = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<any>;
|
||||
view: string;
|
||||
};
|
||||
|
||||
type ToolRegistry = {
|
||||
[key: string]: ToolRegistryEntry;
|
||||
};
|
||||
// Add entry here with maxFiles, endpoints, and lazy component
|
||||
const toolDefinitions: Record<string, ToolDefinition> = {
|
||||
split: {
|
||||
id: "split",
|
||||
icon: <ContentCutIcon />,
|
||||
component: React.lazy(() => import("../tools/Split")),
|
||||
maxFiles: 1,
|
||||
category: "manipulation",
|
||||
description: "Split PDF files into smaller parts",
|
||||
endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"]
|
||||
},
|
||||
compress: {
|
||||
id: "compress",
|
||||
icon: <ZoomInMapIcon />,
|
||||
component: React.lazy(() => import("../tools/Compress")),
|
||||
maxFiles: -1,
|
||||
category: "optimization",
|
||||
description: "Reduce PDF file size",
|
||||
endpoints: ["compress-pdf"]
|
||||
},
|
||||
|
||||
const baseToolRegistry = {
|
||||
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "split" },
|
||||
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "compress" },
|
||||
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "pageEditor" },
|
||||
};
|
||||
|
||||
// Tool endpoint mappings
|
||||
const toolEndpoints: Record<string, string[]> = {
|
||||
split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"],
|
||||
compress: ["compress-pdf"],
|
||||
merge: ["merge-pdfs"],
|
||||
};
|
||||
|
||||
|
||||
export const useToolManagement = () => {
|
||||
interface ToolManagementResult {
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: Tool | null;
|
||||
toolSelectedFileIds: string[];
|
||||
toolRegistry: ToolRegistry;
|
||||
selectTool: (toolKey: string) => void;
|
||||
clearToolSelection: () => void;
|
||||
setToolSelectedFileIds: (fileIds: string[]) => void;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
const allEndpoints = Array.from(new Set(Object.values(toolEndpoints).flat()));
|
||||
const allEndpoints = Array.from(new Set(
|
||||
Object.values(toolDefinitions).flatMap(tool => tool.endpoints || [])
|
||||
));
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
if (endpointsLoading) return true;
|
||||
const endpoints = toolEndpoints[toolKey] || [];
|
||||
return endpoints.some(endpoint => endpointStatus[endpoint] === true);
|
||||
const tool = toolDefinitions[toolKey];
|
||||
if (!tool?.endpoints) return true;
|
||||
return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true);
|
||||
}, [endpointsLoading, endpointStatus]);
|
||||
|
||||
const toolRegistry: ToolRegistry = useMemo(() => {
|
||||
const availableToolRegistry: ToolRegistry = {};
|
||||
Object.keys(baseToolRegistry).forEach(toolKey => {
|
||||
const availableTools: ToolRegistry = {};
|
||||
Object.keys(toolDefinitions).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseToolRegistry[toolKey as keyof typeof baseToolRegistry],
|
||||
const toolDef = toolDefinitions[toolKey];
|
||||
availableTools[toolKey] = {
|
||||
...toolDef,
|
||||
name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1))
|
||||
};
|
||||
}
|
||||
});
|
||||
return availableToolRegistry;
|
||||
return availableTools;
|
||||
}, [t, isToolAvailable]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user