Implement file selection context and integrate with tool management; refactor FileEditor and ToolRenderer for improved file handling

This commit is contained in:
Reece 2025-07-22 15:59:45 +01:00
parent 0549c5b191
commit 405cfd8161
9 changed files with 406 additions and 180 deletions

View File

@ -7,6 +7,7 @@ import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile'; import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext } from '../../contexts/FileContext';
import { useFileSelection } from '../../contexts/FileSelectionContext';
import { FileOperation } from '../../types/fileContext'; import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
@ -31,20 +32,16 @@ interface FileEditorProps {
onOpenPageEditor?: (file: File) => void; onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void; onMergeFiles?: (files: File[]) => void;
toolMode?: boolean; toolMode?: boolean;
multiSelect?: boolean;
showUpload?: boolean; showUpload?: boolean;
showBulkActions?: boolean; showBulkActions?: boolean;
onFileSelect?: (files: File[]) => void;
} }
const FileEditor = ({ const FileEditor = ({
onOpenPageEditor, onOpenPageEditor,
onMergeFiles, onMergeFiles,
toolMode = false, toolMode = false,
multiSelect = true,
showUpload = true, showUpload = true,
showBulkActions = true, showBulkActions = true
onFileSelect
}: FileEditorProps) => { }: FileEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -63,6 +60,14 @@ const FileEditor = ({
markOperationApplied markOperationApplied
} = fileContext; } = fileContext;
// Get file selection context
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useFileSelection();
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -99,14 +104,14 @@ const FileEditor = ({
const lastActiveFilesRef = useRef<string[]>([]); const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(0); const lastProcessedFilesRef = useRef<number>(0);
// Map context selected file names to local file IDs // Get selected file IDs from context (defensive programming)
// Defensive programming: ensure selectedFileIds is always an array const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const localSelectedFiles = files // Map context selections to local file IDs for UI display
const localSelectedIds = files
.filter(file => { .filter(file => {
const fileId = (file.file as any).id || file.name; const fileId = (file.file as any).id || file.name;
return safeSelectedFileIds.includes(fileId); return contextSelectedIds.includes(fileId);
}) })
.map(file => file.id); .map(file => file.id);
@ -396,44 +401,41 @@ const FileEditor = ({
if (!targetFile) return; if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name; const contextFileId = (targetFile.file as any).id || targetFile.name;
const isSelected = contextSelectedIds.includes(contextFileId);
if (!multiSelect) { let newSelection: string[];
// Single select mode for tools - toggle on/off
const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId); if (isSelected) {
if (isCurrentlySelected) { // Remove file from selection
// Deselect the file newSelection = contextSelectedIds.filter(id => id !== contextFileId);
setContextSelectedFiles([]);
if (onFileSelect) {
onFileSelect([]);
}
} else {
// Select the file
setContextSelectedFiles([contextFileId]);
if (onFileSelect) {
onFileSelect([targetFile.file]);
}
}
} else { } else {
// Multi select mode (default) // Add file to selection
setContextSelectedFiles(prev => { if (maxFiles === 1) {
const safePrev = Array.isArray(prev) ? prev : []; newSelection = [contextFileId];
return safePrev.includes(contextFileId) } else {
? safePrev.filter(id => id !== contextFileId) // Check if we've hit the selection limit
: [...safePrev, contextFileId]; if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) {
}); setStatus(`Maximum ${maxFiles} files can be selected`);
return;
// Notify parent with selected files }
if (onFileSelect) { newSelection = [...contextSelectedIds, contextFileId];
const selectedFiles = files
.filter(f => {
const fId = (f.file as any).id || f.name;
return safeSelectedFileIds.includes(fId) || fId === contextFileId;
})
.map(f => f.file);
onFileSelect(selectedFiles);
} }
} }
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
// Update context
setContextSelectedFiles(newSelection);
// Update tool selection context if in tool mode
if (isToolMode || toolMode) {
const selectedFiles = files
.filter(f => {
const fId = (f.file as any).id || f.name;
return newSelection.includes(fId);
})
.map(f => f.file);
setToolSelectedFiles(selectedFiles);
}
}, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]);
const toggleSelectionMode = useCallback(() => { const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => { setSelectionMode(prev => {
@ -450,15 +452,15 @@ const FileEditor = ({
const handleDragStart = useCallback((fileId: string) => { const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId); setDraggedFile(fileId);
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) { if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
setMultiFileDrag({ setMultiFileDrag({
fileIds: localSelectedFiles, fileIds: localSelectedIds,
count: localSelectedFiles.length count: localSelectedIds.length
}); });
} else { } else {
setMultiFileDrag(null); setMultiFileDrag(null);
} }
}, [selectionMode, localSelectedFiles]); }, [selectionMode, localSelectedIds]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setDraggedFile(null); setDraggedFile(null);
@ -519,8 +521,8 @@ const FileEditor = ({
if (targetIndex === -1) return; if (targetIndex === -1) return;
} }
const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile) const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
? localSelectedFiles ? localSelectedIds
: [draggedFile]; : [draggedFile];
// Update the local files state and sync with activeFiles // Update the local files state and sync with activeFiles
@ -545,7 +547,7 @@ const FileEditor = ({
const moveCount = multiFileDrag ? multiFileDrag.count : 1; const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]); }, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => { const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) { if (draggedFile) {
@ -764,7 +766,7 @@ const FileEditor = ({
) : ( ) : (
<DragDropGrid <DragDropGrid
items={files} items={files}
selectedItems={localSelectedFiles} selectedItems={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
isAnimating={isAnimating} isAnimating={isAnimating}
onDragStart={handleDragStart} onDragStart={handleDragStart}
@ -783,7 +785,7 @@ const FileEditor = ({
file={file} file={file}
index={index} index={index}
totalFiles={files.length} totalFiles={files.length}
selectedFiles={localSelectedFiles} selectedFiles={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
draggedFile={draggedFile} draggedFile={draggedFile}
dropTarget={dropTarget} dropTarget={dropTarget}

View File

@ -1,15 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core"; import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ToolRegistry } from "../../types/tool";
type Tool = {
icon: React.ReactNode;
name: string;
};
type ToolRegistry = {
[id: string]: Tool;
};
interface ToolPickerProps { interface ToolPickerProps {
selectedToolKey: string | null; selectedToolKey: string | null;

View File

@ -1,23 +1,29 @@
import { FileWithUrl } from "../../types/file"; import React, { Suspense } from "react";
import { Loader, Center, Stack, Text } from "@mantine/core";
import { useToolManagement } from "../../hooks/useToolManagement"; import { useToolManagement } from "../../hooks/useToolManagement";
import { BaseToolProps } from "../../types/tool";
interface ToolRendererProps { interface ToolRendererProps extends BaseToolProps {
selectedToolKey: string; selectedToolKey: string;
pdfFile: any;
files: FileWithUrl[];
toolParams: any;
updateParams: (params: any) => void;
toolSelectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
} }
// Loading fallback component for lazy-loaded tools
const ToolLoadingFallback = ({ toolName }: { toolName?: string }) => (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{toolName ? `Loading ${toolName}...` : "Loading tool..."}
</Text>
</Stack>
</Center>
);
const ToolRenderer = ({ const ToolRenderer = ({
selectedToolKey, selectedToolKey,
files,
toolParams,
updateParams,
toolSelectedFiles = [],
onPreviewFile, onPreviewFile,
onComplete,
onError,
}: ToolRendererProps) => { }: ToolRendererProps) => {
// Get the tool from registry // Get the tool from registry
const { toolRegistry } = useToolManagement(); const { toolRegistry } = useToolManagement();
@ -29,41 +35,16 @@ files,
const ToolComponent = selectedTool.component; const ToolComponent = selectedTool.component;
// Pass tool-specific props // Wrap lazy-loaded component with Suspense
switch (selectedToolKey) { return (
case "split": <Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
return ( <ToolComponent
<ToolComponent onPreviewFile={onPreviewFile}
selectedFiles={toolSelectedFiles} onComplete={onComplete}
onPreviewFile={onPreviewFile} onError={onError}
/> />
); </Suspense>
case "compress": );
return (
<ToolComponent
files={files}
setLoading={(loading: boolean) => {}}
params={toolParams}
updateParams={updateParams}
/>
);
case "merge":
return (
<ToolComponent
files={files}
params={toolParams}
updateParams={updateParams}
/>
);
default:
return (
<ToolComponent
files={files}
params={toolParams}
updateParams={updateParams}
/>
);
}
}; };
export default ToolRenderer; export default ToolRenderer;

View File

@ -98,7 +98,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
case 'REMOVE_FILES': case 'REMOVE_FILES':
const remainingFiles = state.activeFiles.filter(file => { const remainingFiles = state.activeFiles.filter(file => {
const fileId = (file as any).id || file.name; const fileId = (file as File & { id?: string }).id || file.name;
return !action.payload.includes(fileId); return !action.payload.includes(fileId);
}); });
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : []; const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
@ -347,7 +347,7 @@ export function FileContextProvider({
// Cleanup timers and refs // Cleanup timers and refs
const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map()); const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
const blobUrls = useRef<Set<string>>(new Set()); const blobUrls = useRef<Set<string>>(new Set());
const pdfDocuments = useRef<Map<string, any>>(new Map()); const pdfDocuments = useRef<Map<string, PDFDocument>>(new Map());
// Enhanced file processing hook // Enhanced file processing hook
const { const {
@ -381,7 +381,7 @@ export function FileContextProvider({
blobUrls.current.add(url); blobUrls.current.add(url);
}, []); }, []);
const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => { const trackPdfDocument = useCallback((fileId: string, pdfDoc: PDFDocument) => {
// Clean up existing document for this file if any // Clean up existing document for this file if any
const existing = pdfDocuments.current.get(fileId); const existing = pdfDocuments.current.get(fileId);
if (existing && existing.destroy) { if (existing && existing.destroy) {
@ -498,7 +498,7 @@ export function FileContextProvider({
for (const file of files) { for (const file of files) {
try { try {
// Check if file already has an ID (already in IndexedDB) // Check if file already has an ID (already in IndexedDB)
const fileId = (file as any).id; const fileId = (file as File & { id?: string }).id;
if (!fileId) { if (!fileId) {
// File doesn't have ID, store it and get the ID // File doesn't have ID, store it and get the ID
const storedFile = await fileStorage.storeFile(file); const storedFile = await fileStorage.storeFile(file);
@ -680,7 +680,7 @@ export function FileContextProvider({
// Utility functions // Utility functions
const getFileById = useCallback((fileId: string): File | undefined => { const getFileById = useCallback((fileId: string): File | undefined => {
return state.activeFiles.find(file => { return state.activeFiles.find(file => {
const actualFileId = (file as any).id || file.name; const actualFileId = (file as File & { id?: string }).id || file.name;
return actualFileId === fileId; return actualFileId === fileId;
}); });
}, [state.activeFiles]); }, [state.activeFiles]);

View File

@ -0,0 +1,85 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import {
MaxFiles,
FileSelectionState,
FileSelectionActions,
FileSelectionComputed,
FileSelectionContextValue
} from '../types/tool';
interface FileSelectionProviderProps {
children: ReactNode;
}
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
// State
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1); // Default: unlimited
const [isToolMode, setIsToolMode] = useState<boolean>(false);
// Actions
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
// Computed properties
const selectionCount = selectedFiles.length;
const canSelectMore = maxFiles === -1 || selectionCount < maxFiles;
const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles;
const isMultiFileMode = maxFiles !== 1;
const contextValue: FileSelectionContextValue = {
// State
selectedFiles,
maxFiles,
isToolMode,
// Actions
setSelectedFiles,
setMaxFiles,
setIsToolMode,
clearSelection,
// Computed
canSelectMore,
isAtLimit,
selectionCount,
isMultiFileMode
};
return (
<FileSelectionContext.Provider value={contextValue}>
{children}
</FileSelectionContext.Provider>
);
}
// Custom hook to use the context
export function useFileSelection(): FileSelectionContextValue {
const context = useContext(FileSelectionContext);
if (!context) {
throw new Error('useFileSelection must be used within a FileSelectionProvider');
}
return context;
}
// Helper hooks for specific use cases with strict typing
export function useToolFileSelection(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection();
return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount };
}
export function useFileSelectionActions(): Pick<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection();
return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode };
}
export function useFileSelectionState(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
const { selectedFiles, maxFiles, isToolMode } = useFileSelection();
return { selectedFiles, maxFiles, isToolMode };
}
export function useFileSelectionComputed(): Pick<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
}

View File

@ -3,62 +3,88 @@ import { useTranslation } from 'react-i18next';
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos"; import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import ContentCutIcon from "@mui/icons-material/ContentCut"; import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap"; 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 { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { Tool, ToolDefinition, BaseToolProps, ToolRegistry } from "../types/tool";
type ToolRegistryEntry = { // Import types from central location - no need to redefine
icon: React.ReactNode;
name: string;
component: React.ComponentType<any>;
view: string;
};
type ToolRegistry = { // Tool definitions using simplified interface
[key: string]: ToolRegistryEntry; // IMPORTANT: Adding a new tool is just 2 steps:
}; // 1. Add entry here with maxFiles, endpoints, and lazy component
// 2. Create the tool component - NO HomePage changes needed!
const baseToolRegistry = { // The system automatically handles FileEditor, file selection, and rendering
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "split" }, const toolDefinitions: Record<string, ToolDefinition> = {
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "viewer" }, split: {
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "pageEditor" }, id: "split",
}; icon: <ContentCutIcon />,
component: React.lazy(() => import("../tools/Split")),
// Tool endpoint mappings maxFiles: 1,
const toolEndpoints: Record<string, string[]> = { category: "manipulation",
split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"], description: "Split PDF files into smaller parts",
compress: ["compress-pdf"], endpoints: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"]
merge: ["merge-pdfs"], },
// compress and merge are disabled for now - legacy tools to be overhauled
// compress: {
// id: "compress",
// icon: <ZoomInMapIcon />,
// component: React.lazy(() => import("../tools/Compress")),
// maxFiles: 1,
// category: "optimization",
// description: "Reduce PDF file size",
// endpoints: ["compress-pdf"]
// },
// merge: {
// id: "merge",
// icon: <AddToPhotosIcon />,
// component: React.lazy(() => import("../tools/Merge")),
// maxFiles: -1,
// category: "manipulation",
// description: "Combine multiple PDF files",
// endpoints: ["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 { t } = useTranslation();
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null); const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]); 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 { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => { const isToolAvailable = useCallback((toolKey: string): boolean => {
if (endpointsLoading) return true; if (endpointsLoading) return true;
const endpoints = toolEndpoints[toolKey] || []; const tool = toolDefinitions[toolKey];
return endpoints.some(endpoint => endpointStatus[endpoint] === true); if (!tool?.endpoints) return true;
return tool.endpoints.some(endpoint => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus]); }, [endpointsLoading, endpointStatus]);
const toolRegistry: ToolRegistry = useMemo(() => { const toolRegistry: ToolRegistry = useMemo(() => {
const availableToolRegistry: ToolRegistry = {}; const availableTools: ToolRegistry = {};
Object.keys(baseToolRegistry).forEach(toolKey => { Object.keys(toolDefinitions).forEach(toolKey => {
if (isToolAvailable(toolKey)) { if (isToolAvailable(toolKey)) {
availableToolRegistry[toolKey] = { const toolDef = toolDefinitions[toolKey];
...baseToolRegistry[toolKey as keyof typeof baseToolRegistry], availableTools[toolKey] = {
...toolDef,
name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1)) name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1))
}; };
} }
}); });
return availableToolRegistry; return availableTools;
}, [t, isToolAvailable]); }, [t, isToolAvailable]);
useEffect(() => { useEffect(() => {

View File

@ -1,6 +1,7 @@
import React, { useState, useCallback} from "react"; import React, { useState, useCallback, useEffect} from "react";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
import { useToolManagement } from "../hooks/useToolManagement"; import { useToolManagement } from "../hooks/useToolManagement";
import { Group, Box, Button, Container } from "@mantine/core"; import { Group, Box, Button, Container } from "@mantine/core";
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider"; import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
@ -17,7 +18,8 @@ import ToolRenderer from "../components/tools/ToolRenderer";
import QuickAccessBar from "../components/shared/QuickAccessBar"; import QuickAccessBar from "../components/shared/QuickAccessBar";
import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig"; import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig";
export default function HomePage() { // Inner component that uses file selection context
function HomePageContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
@ -25,35 +27,60 @@ export default function HomePage() {
const fileContext = useFileContext(); const fileContext = useFileContext();
const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext; const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext;
// Get file selection context
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
const { const {
selectedToolKey, selectedToolKey,
selectedTool, selectedTool,
toolParams, toolSelectedFileIds,
toolRegistry, toolRegistry,
selectTool, selectTool,
clearToolSelection, clearToolSelection,
updateToolParams, setToolSelectedFileIds,
} = useToolManagement(); } = useToolManagement();
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
const [sidebarsVisible, setSidebarsVisible] = useState(true); const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false); const [readerMode, setReaderMode] = useState(false);
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null); const [pageEditorFunctions, setPageEditorFunctions] = useState<{
closePdf: () => void;
handleUndo: () => void;
handleRedo: () => void;
canUndo: boolean;
canRedo: boolean;
handleRotate: () => void;
handleDelete: () => void;
handleSplit: () => void;
onExportSelected: () => void;
onExportAll: () => void;
exportLoading: boolean;
selectionMode: boolean;
selectedPages: number[];
} | null>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null); const [previewFile, setPreviewFile] = useState<File | null>(null);
// Update file selection context when tool changes
useEffect(() => {
if (selectedTool) {
setMaxFiles(selectedTool.maxFiles);
setIsToolMode(true);
} else {
setMaxFiles(-1); // Unlimited when not in tool mode
setIsToolMode(false);
setSelectedFiles([]); // Clear selection when exiting tool mode
}
}, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]);
const handleToolSelect = useCallback( const handleToolSelect = useCallback(
(id: string) => { (id: string) => {
selectTool(id); selectTool(id);
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view); setCurrentView('fileEditor'); // Tools use fileEditor view for file selection
setLeftPanelView('toolContent'); setLeftPanelView('toolContent');
setReaderMode(false); setReaderMode(false);
}, },
[selectTool, toolRegistry, setCurrentView] [selectTool, setCurrentView]
); );
const handleQuickAccessTools = useCallback(() => { const handleQuickAccessTools = useCallback(() => {
@ -145,7 +172,6 @@ export default function HomePage() {
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<ToolRenderer <ToolRenderer
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolKey}
toolSelectedFiles={toolSelectedFiles}
onPreviewFile={setPreviewFile} onPreviewFile={setPreviewFile}
/> />
</div> </div>
@ -194,7 +220,15 @@ export default function HomePage() {
maxRecentFiles={8} maxRecentFiles={8}
/> />
</Container> </Container>
) : currentView === "fileEditor" && selectedToolKey ? (
// Tool-specific FileEditor - for file selection with tools
<FileEditor
toolMode={true}
showUpload={true}
showBulkActions={false}
/>
) : currentView === "fileEditor" ? ( ) : currentView === "fileEditor" ? (
// Generic FileEditor - for general file management
<FileEditor <FileEditor
onOpenPageEditor={(file) => { onOpenPageEditor={(file) => {
handleViewChange("pageEditor"); handleViewChange("pageEditor");
@ -248,17 +282,8 @@ export default function HomePage() {
/> />
)} )}
</> </>
) : currentView === "split" ? (
<FileEditor
toolMode={true}
multiSelect={false}
showUpload={true}
showBulkActions={true}
onFileSelect={(files) => {
setToolSelectedFiles(files);
}}
/>
) : selectedToolKey && selectedTool ? ( ) : selectedToolKey && selectedTool ? (
// Fallback: if tool is selected but not in fileEditor view, show tool in main area
<ToolRenderer <ToolRenderer
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolKey}
/> />
@ -285,3 +310,12 @@ export default function HomePage() {
</Group> </Group>
); );
} }
// Main HomePage component wrapped with FileSelectionProvider
export default function HomePage() {
return (
<FileSelectionProvider>
<HomePageContent />
</FileSelectionProvider>
);
}

View File

@ -4,26 +4,23 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";
import ErrorNotification from "../components/tools/shared/ErrorNotification"; import ErrorNotification from "../components/tools/shared/ErrorNotification";
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator"; import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
import ResultsPreview from "../components/tools/shared/ResultsPreview"; import ResultsPreview from "../components/tools/shared/ResultsPreview";
import SplitSettings from "../components/tools/split/SplitSettings"; import SplitSettings from "../components/tools/split/SplitSettings";
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters"; import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation"; import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
import { BaseToolProps } from "../types/tool";
interface SplitProps { const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
selectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setCurrentMode } = useFileContext(); const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const splitParams = useSplitParameters(); const splitParams = useSplitParameters();
const splitOperation = useSplitOperation(); const splitOperation = useSplitOperation();
@ -39,11 +36,20 @@ const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
}, [splitParams.mode, splitParams.parameters, selectedFiles]); }, [splitParams.mode, splitParams.parameters, selectedFiles]);
const handleSplit = async () => { const handleSplit = async () => {
await splitOperation.executeOperation( try {
splitParams.mode, await splitOperation.executeOperation(
splitParams.parameters, splitParams.mode,
selectedFiles splitParams.parameters,
); selectedFiles
);
if (splitOperation.files && onComplete) {
onComplete(splitOperation.files);
}
} catch (error) {
if (onError) {
onError(error instanceof Error ? error.message : 'Split operation failed');
}
}
}; };
const handleThumbnailClick = (file: File) => { const handleThumbnailClick = (file: File) => {

100
frontend/src/types/tool.ts Normal file
View File

@ -0,0 +1,100 @@
import React from 'react';
// Type definitions for better type safety
export type MaxFiles = number; // 1 = single file, >1 = limited multi-file, -1 = unlimited
export type ToolCategory = 'manipulation' | 'conversion' | 'analysis' | 'utility' | 'optimization' | 'security';
/**
* Tool definition without name - used for base definitions before translation
*/
export type ToolDefinition = Omit<Tool, 'name'>;
/**
* Standard interface that all modern tools should implement
* This ensures consistent behavior and makes adding new tools trivial
*/
export interface BaseToolProps {
// Tool results callback - called when tool completes successfully
onComplete?: (results: File[]) => void;
// Error handling callback
onError?: (error: string) => void;
// Preview functionality for result files
onPreviewFile?: (file: File | null) => void;
}
/**
* Tool step types for standardized UI
*/
export type ToolStepType = 'files' | 'settings' | 'results';
/**
* Tool step configuration
*/
export interface ToolStepConfig {
type: ToolStepType;
title: string;
isVisible: boolean;
isCompleted: boolean;
isCollapsed?: boolean;
completedMessage?: string;
onCollapsedClick?: () => void;
}
/**
* Tool operation result
*/
export interface ToolResult {
success: boolean;
files?: File[];
error?: string;
downloadUrl?: string;
metadata?: Record<string, any>;
}
/**
* Complete tool definition - single interface for all tool needs
*/
export interface Tool {
id: string;
name: string; // Always required - added via translation
icon: React.ReactNode; // Always required - for UI display
component: React.ComponentType<BaseToolProps>; // Lazy-loaded tool component
maxFiles: MaxFiles; // File selection limit: 1=single, 5=limited, -1=unlimited
category?: ToolCategory; // Tool grouping for organization
description?: string; // Help text for users
endpoints?: string[]; // Backend endpoints this tool uses
supportedFormats?: string[]; // File types this tool accepts
validation?: (files: File[]) => { valid: boolean; message?: string }; // File validation logic
}
/**
* Tool registry type - tools indexed by key
*/
export type ToolRegistry = Record<string, Tool>;
/**
* File selection context interfaces for type safety
*/
export interface FileSelectionState {
selectedFiles: File[];
maxFiles: MaxFiles;
isToolMode: boolean;
}
export interface FileSelectionActions {
setSelectedFiles: (files: File[]) => void;
setMaxFiles: (maxFiles: MaxFiles) => void;
setIsToolMode: (isToolMode: boolean) => void;
clearSelection: () => void;
}
export interface FileSelectionComputed {
canSelectMore: boolean;
isAtLimit: boolean;
selectionCount: number;
isMultiFileMode: boolean;
}
export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {}