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:
Reece Browne
2025-07-25 09:37:52 +01:00
committed by GitHub
parent 4e5f595951
commit e4d480c7b3
15 changed files with 538 additions and 301 deletions

View File

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