Feature/v2/file handling improvements (#4222)

# Description of Changes

A new universal file context rather than the splintered ones for the
main views, tools and manager we had before (manager still has its own
but its better integreated with the core context)
File context has been split it into a handful of different files
managing various file related issues separately to reduce the monolith -
FileReducer.ts - State management
  fileActions.ts - File operations
  fileSelectors.ts - Data access patterns
  lifecycle.ts - Resource cleanup and memory management
  fileHooks.ts - React hooks interface
  contexts.ts - Context providers
Improved thumbnail generation
Improved indexxedb handling
Stopped handling files as blobs were not necessary to improve
performance
A new library handling drag and drop
https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes
but I broke the old one with the new filecontext and it needed doing so
it was a might as well)
A new library handling virtualisation on page editor
@tanstack/react-virtual, as above.
Quickly ripped out the last remnants of the old URL params stuff and
replaced with the beginnings of what will later become the new URL
navigation system (for now it just restores the tool name in url
behavior)
Fixed selected file not regestered when opening a tool
Fixed png thumbnails
Closes #(issue_number)

---

## 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.

---------

Co-authored-by: Reece Browne <you@example.com>
This commit is contained in:
Reece Browne
2025-08-21 17:30:26 +01:00
committed by GitHub
parent a33e51351b
commit 949ffa01ad
90 changed files with 5416 additions and 4164 deletions

View File

@@ -1,136 +0,0 @@
import React from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import { FileWithUrl } from "../types/file";
import { getFileSize, getFileDate } from "../utils/fileUtils";
import { useIndexedDBThumbnail } from "../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: FileWithUrl;
onRemove: () => void;
onDoubleClick?: () => void;
}
const FileCard: React.FC<FileCardProps> = ({ file, onRemove, onDoubleClick }) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined
}}
onDoubleClick={onDoubleClick}
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
}}
>
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
)}
</div>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="gray" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
DB
</Badge>
)}
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={onRemove}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
};
export default FileCard;

View File

@@ -1,9 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { FileWithUrl } from '../types/file';
import { FileMetadata } from '../types/file';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { createFileId } from '../types/fileContext';
import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
@@ -15,13 +16,19 @@ interface FileManagerProps {
}
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// Wrapper for storeFile that generates UUID
const storeFileWithId = useCallback(async (file: File) => {
const fileId = createFileId(); // Generate UUID for storage
return await storeFile(file, fileId);
}, [storeFile]);
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
@@ -34,18 +41,21 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
setRecentFiles(files);
}, [loadRecentFiles]);
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
try {
const fileObjects = await Promise.all(
files.map(async (fileWithUrl) => {
return await convertToFile(fileWithUrl);
})
// Use stored files flow that preserves original IDs
const filesWithMetadata = await Promise.all(
files.map(async (metadata) => ({
file: await convertToFile(metadata),
originalId: metadata.id,
metadata
}))
);
onFilesSelect(fileObjects);
onStoredFilesSelect(filesWithMetadata);
} catch (error) {
console.error('Failed to process selected files:', error);
}
}, [convertToFile, onFilesSelect]);
}, [convertToFile, onStoredFilesSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) {
@@ -82,14 +92,11 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Cleanup any blob URLs when component unmounts
useEffect(() => {
return () => {
// Clean up blob URLs from recent files
recentFiles.forEach(file => {
if (file.url && file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
});
// FileMetadata doesn't have blob URLs, so no cleanup needed
// Blob URLs are managed by FileContext and tool operations
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
};
}, [recentFiles]);
}, []);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
@@ -130,7 +137,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
accept={["*/*"] as any}
accept={{}}
multiple={true}
activateOnClick={false}
style={{
@@ -147,12 +154,12 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
<FileManagerProvider
recentFiles={recentFiles}
onFilesSelected={handleFilesSelected}
onNewFilesSelect={handleNewFileUpload}
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
storeFile={storeFile}
refreshRecentFiles={refreshRecentFiles}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
@@ -6,8 +6,8 @@ import {
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 { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
@@ -15,19 +15,9 @@ import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail';
import DragDropGrid from '../pageEditor/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
size: number;
file: File;
splitBefore?: boolean;
}
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
@@ -54,33 +44,25 @@ const FileEditor = ({
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
// Get file context
const fileContext = useFileContext();
const {
activeFiles,
processedFiles,
selectedFileIds,
setSelectedFiles: setContextSelectedFiles,
isProcessing,
addFiles,
removeFiles,
setCurrentView,
recordOperation,
markOperationApplied
} = fileContext;
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
// Extract needed values from state (memoized to prevent infinite loops)
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing;
// Get the real context actions
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions();
// Get file selection context
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useFileSelection();
const { setSelectedFiles } = useFileSelection();
const [files, setFiles] = useState<FileItem[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localLoading, setLocalLoading] = useState(false);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
@@ -89,13 +71,7 @@ const FileEditor = ({
setSelectionMode(true);
}
}, [toolMode]);
const [draggedFile, setDraggedFile] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [conversionProgress, setConversionProgress] = useState(0);
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
isExtracting: boolean;
currentFile: string;
@@ -109,115 +85,30 @@ const FileEditor = ({
extractedCount: 0,
totalFiles: 0
});
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(0);
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
// Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<string[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// 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 contextSelectedIds.includes(fileId);
})
.map(file => file.id);
// Convert shared files to FileEditor format
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Generate thumbnail if not already available
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
// Use activeFileRecords directly - no conversion needed
const localSelectedIds = contextSelectedIds;
// Helper to convert FileRecord to FileThumbnail format
const recordToFileItem = useCallback((record: any) => {
const file = selectors.getFile(record.id);
if (!file) return null;
return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile,
id: record.id,
name: file.name,
pageCount: record.processedFile?.totalPages || 1,
thumbnail: record.thumbnailUrl || '',
size: file.size,
file: file
};
}, []);
// Convert activeFiles to FileItem format using context (async to avoid blocking)
useEffect(() => {
// Check if the actual content has changed, not just references
const currentActiveFileNames = activeFiles.map(f => f.name);
const currentProcessedFilesSize = processedFiles.size;
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
if (!activeFilesChanged && !processedFilesChanged) {
return;
}
// Update refs
lastActiveFilesRef.current = currentActiveFileNames;
lastProcessedFilesRef.current = currentProcessedFilesSize;
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
setLocalLoading(true);
try {
// Process files in chunks to avoid blocking UI
const convertedFiles: FileItem[] = [];
for (let i = 0; i < activeFiles.length; i++) {
const file = activeFiles[i];
// Try to get thumbnail from processed file first
const processedFile = processedFiles.get(file);
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
// If no thumbnail from processed file, try to generate one
if (!thumbnail) {
try {
thumbnail = await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
thumbnail = undefined; // Use placeholder
}
}
const convertedFile = {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name,
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
thumbnail: thumbnail || '',
size: file.size,
file,
};
convertedFiles.push(convertedFile);
// Update progress
setConversionProgress(((i + 1) / activeFiles.length) * 100);
// Yield to main thread between files
if (i < activeFiles.length - 1) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting active files:', err);
} finally {
setLocalLoading(false);
setConversionProgress(0);
}
} else {
setFiles([]);
setLocalLoading(false);
setConversionProgress(0);
}
};
convertActiveFiles();
}, [activeFiles, processedFiles]);
}, [selectors]);
// Process uploaded files using context
@@ -289,10 +180,7 @@ const FileEditor = ({
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
@@ -301,7 +189,6 @@ const FileEditor = ({
}
} else {
// ZIP doesn't contain PDFs or is invalid - treat as regular file
console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`);
allExtractedFiles.push(file);
}
} catch (zipError) {
@@ -315,7 +202,6 @@ const FileEditor = ({
});
}
} else {
console.log(`Adding none PDF file: ${file.name} (${file.type})`);
allExtractedFiles.push(file);
}
}
@@ -344,9 +230,6 @@ const FileEditor = ({
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
}
// Add files to context (they will be processed automatically)
@@ -357,7 +240,7 @@ const FileEditor = ({
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
setZipExtractionProgress({
isExtracting: false,
@@ -367,220 +250,137 @@ const FileEditor = ({
totalFiles: 0
});
}
}, [addFiles, recordOperation, markOperationApplied]);
}, [addFiles]);
const selectAll = useCallback(() => {
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
}, [files, setContextSelectedFiles]);
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
}, [activeFileRecords, setSelectedFiles]);
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFiles.length === 0) return;
// Record close all operation for each file
activeFiles.forEach(file => {
const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [file.name],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
action: 'close_all',
reason: 'user_request'
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
});
if (activeFileRecords.length === 0) return;
// Remove all files from context but keep in storage
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
const allFileIds = activeFileRecords.map(record => record.id);
removeFiles(allFileIds, false); // false = keep in storage
// Clear selections
setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
setSelectedFiles([]);
}, [activeFileRecords, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: string) => {
const targetFile = files.find(f => f.id === fileId);
if (!targetFile) return;
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeFileRecords.find(r => r.id === fileId);
if (!targetRecord) return;
const contextFileId = (targetFile.file as any).id || targetFile.name;
const isSelected = contextSelectedIds.includes(contextFileId);
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: string[];
if (isSelected) {
// Remove file from selection
newSelection = contextSelectedIds.filter(id => id !== contextFileId);
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
} else {
// Add file to selection
if (maxFiles === 1) {
// In tool mode, typically allow multiple files unless specified otherwise
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
if (maxAllowed === 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`);
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
setStatus(`Maximum ${maxAllowed} files can be selected`);
return;
}
newSelection = [...contextSelectedIds, contextFileId];
newSelection = [...currentSelectedIds, contextFileId];
}
}
// 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]);
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setContextSelectedFiles([]);
setSelectedFiles([]);
}
return newMode;
});
}, [setContextSelectedFiles]);
}, [setSelectedFiles]);
// Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId);
if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
setMultiFileDrag({
fileIds: localSelectedIds,
count: localSelectedIds.length
});
} else {
setMultiFileDrag(null);
}
}, [selectionMode, localSelectedIds]);
const handleDragEnd = useCallback(() => {
setDraggedFile(null);
setDropTarget(null);
setMultiFileDrag(null);
setDragPosition(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!draggedFile) return;
if (multiFileDrag) {
setDragPosition({ x: e.clientX, y: e.clientY });
}
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
if (!elementUnderCursor) return;
const fileContainer = elementUnderCursor.closest('[data-file-id]');
if (fileContainer) {
const fileId = fileContainer.getAttribute('data-file-id');
if (fileId && fileId !== draggedFile) {
setDropTarget(fileId);
return;
}
}
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
if (endZone) {
setDropTarget('end');
// File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => {
const currentIds = activeFileRecords.map(r => r.id);
// Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
const targetIndex = currentIds.findIndex(id => id === targetFileId);
if (sourceIndex === -1 || targetIndex === -1) {
console.warn('Could not find source or target file for reordering');
return;
}
setDropTarget(null);
}, [draggedFile, multiFileDrag]);
// Handle multi-file selection reordering
const filesToMove = selectedFileIds.length > 1
? selectedFileIds.filter(id => currentIds.includes(id))
: [sourceFileId];
const handleDragEnter = useCallback((fileId: string) => {
if (draggedFile && fileId !== draggedFile) {
setDropTarget(fileId);
}
}, [draggedFile]);
const handleDragLeave = useCallback(() => {
// Let dragover handle this
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
e.preventDefault();
if (!draggedFile || draggedFile === targetFileId) return;
let targetIndex: number;
if (targetFileId === 'end') {
targetIndex = files.length;
} else {
targetIndex = files.findIndex(f => f.id === targetFileId);
if (targetIndex === -1) return;
}
const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
? localSelectedIds
: [draggedFile];
// Update the local files state and sync with activeFiles
setFiles(prev => {
const newFiles = [...prev];
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
// Remove moved files
filesToMove.forEach(id => {
const index = newFiles.findIndex(f => f.id === id);
if (index !== -1) newFiles.splice(index, 1);
});
// Insert at target position
newFiles.splice(targetIndex, 0, ...movedFiles);
// TODO: Update context with reordered files (need to implement file reordering in context)
// For now, just return the reordered local state
return newFiles;
// Create new order
const newOrder = [...currentIds];
// Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
.sort((a, b) => b - a); // Sort descending
sourceIndices.forEach(index => {
newOrder.splice(index, 1);
});
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
setDropTarget('end');
// Calculate insertion index after removals
let insertIndex = newOrder.findIndex(id => id === targetFileId);
if (insertIndex !== -1) {
// Determine if moving forward or backward
const isMovingForward = sourceIndex < targetIndex;
if (isMovingForward) {
// Moving forward: insert after target
insertIndex += 1;
} else {
// Moving backward: insert before target (insertIndex already correct)
}
} else {
// Target was moved, insert at end
insertIndex = newOrder.length;
}
}, [draggedFile]);
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
// Update file order
reorderFiles(newOrder);
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeFileRecords, reorderFiles, setStatus]);
// File operations using context
const handleDeleteFile = useCallback((fileId: string) => {
console.log('handleDeleteFile called with fileId:', fileId);
const file = files.find(f => f.id === fileId);
console.log('Found file:', file);
if (file) {
console.log('Attempting to remove file:', file.name);
console.log('Actual file object:', file.file);
console.log('Actual file.file.name:', file.file.name);
const record = activeFileRecords.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Record close operation
const fileName = file.file.name;
const fileId = (file.file as any).id || fileName;
const fileName = file.name;
const contextFileId = record.id;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
@@ -590,75 +390,62 @@ const FileEditor = ({
status: 'pending',
metadata: {
originalFileName: fileName,
fileSize: file.size,
fileSize: record.size,
parameters: {
action: 'close',
reason: 'user_request'
}
}
};
recordOperation(fileName, operation);
// Remove file from context but keep in storage (close, don't delete)
console.log('Calling removeFiles with:', [fileId]);
removeFiles([fileId], false);
removeFiles([contextFileId], false);
// Remove from context selections
const newSelection = contextSelectedIds.filter(id => id !== fileId);
setContextSelectedFiles(newSelection);
// Mark operation as applied
markOperationApplied(fileName, operationId);
} else {
console.log('File not found for fileId:', fileId);
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file) {
// Set the file as selected in context and switch to page editor view
const contextFileId = (file.file as any).id || file.name;
setContextSelectedFiles([contextFileId]);
setCurrentView('pageEditor');
onOpenPageEditor?.(file.file);
const record = activeFileRecords.find(r => r.id === fileId);
if (record) {
// Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId]);
navActions.setMode('viewer');
}
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]);
}, [activeFileRecords, setSelectedFiles, navActions.setMode]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
if (startIndex === -1) return;
const filesToMerge = files.slice(startIndex).map(f => f.file);
const recordsToMerge = activeFileRecords.slice(startIndex);
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [files, onMergeFiles]);
}, [activeFileRecords, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
const file = selectors.getFile(fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
onOpenPageEditor(file);
}
}, [files, onOpenPageEditor]);
}, [selectors, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return;
setLocalLoading(true);
try {
const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem)
);
setFiles(prev => [...prev, ...convertedFiles]);
// Use FileContext to handle loading stored files
// The files are already in FileContext, just need to add them to active files
setStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage');
} finally {
setLocalLoading(false);
}
}, [convertToFileItem]);
}, []);
return (
@@ -680,10 +467,14 @@ const FileEditor = ({
<Box p="md" pt="xl">
<Group mb="md">
{showBulkActions && !toolMode && (
{toolMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{showBulkActions && !toolMode && (
<>
<Button onClick={closeAllFiles} variant="light" color="orange">
Close All
</Button>
@@ -692,7 +483,7 @@ const FileEditor = ({
</Group>
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
@@ -700,7 +491,7 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
<Box>
<SkeletonLoader type="controls" />
@@ -734,88 +525,42 @@ const FileEditor = ({
</Box>
)}
{/* Processing indicator */}
{localLoading && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Loading files...</Text>
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(conversionProgress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-blue-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
)}
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<DragDropGrid
items={files}
selectedItems={localSelectedIds as any /* FIX ME */}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart as any /* FIX ME */}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter as any /* FIX ME */}
onDragLeave={handleDragLeave}
onDrop={handleDrop as any /* FIX ME */}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile as any /* FIX ME */}
dropTarget={dropTarget as any /* FIX ME */}
multiItemDrag={multiFileDrag as any /* FIX ME */}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
<FileThumbnail
file={file}
index={index}
totalFiles={files.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
isAnimating={isAnimating}
fileRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/>
)}
renderSplitMarker={(file, index) => (
<div
style={{
width: '2px',
height: '24rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
{activeFileRecords.map((record, index) => {
const fileItem = recordToFileItem(record);
if (!fileItem) return null;
return (
<FileThumbnail
key={record.id}
file={fileItem}
index={index}
totalFiles={activeFileRecords.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
onReorderFiles={handleReorderFiles}
toolMode={toolMode}
isSupported={isFileSupported(fileItem.name)}
/>
);
})}
</div>
)}
</Box>

View File

@@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface CompactFileDetailsProps {
currentFile: FileWithUrl | null;
currentFile: FileMetadata | null;
thumbnail: string | null;
selectedFiles: FileWithUrl[];
selectedFiles: FileMetadata[];
currentFileIndex: number;
numberOfFiles: number;
isAnimating: boolean;

View File

@@ -2,10 +2,10 @@ import React from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface FileInfoCardProps {
currentFile: FileWithUrl | null;
currentFile: FileMetadata | null;
modalHeight: string;
}

View File

@@ -52,9 +52,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
) : (
filteredFiles.map((file, index) => (
<FileListItem
key={file.id || file.name}
key={file.id}
file={file}
isSelected={selectedFilesSet.has(file.id || file.name)}
isSelected={selectedFilesSet.has(file.id)}
isSupported={isFileSupported(file.name)}
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
onRemove={() => onFileRemove(index)}

View File

@@ -1,14 +1,14 @@
import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu } from '@mantine/core';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface FileListItemProps {
file: FileWithUrl;
file: FileMetadata;
isSelected: boolean;
isSupported: boolean;
onSelect: (shiftKey?: boolean) => void;
@@ -70,7 +70,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{file.name}</Text>
<Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
{file.isDraft && (
<Badge size="xs" variant="light" color="orange">
DRAFT
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box>

View File

@@ -11,7 +11,7 @@ import {
Code,
Divider
} from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
// FileContext no longer needed - these were stub functions anyway
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
@@ -26,11 +26,13 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
showOnlyApplied = false,
maxHeight = 400
}) => {
const { getFileHistory, getAppliedOperations } = useFileContext();
// These were stub functions in the old context - replace with empty stubs
const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
const getAppliedOperations = (fileId: string) => [];
const history = getFileHistory(fileId);
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[];
const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();

View File

@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileContext } from '../../contexts/FileContext';
import { useFileState, useFileActions } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import TopControls from '../shared/TopControls';
import FileEditor from '../fileEditor/FileEditor';
@@ -20,7 +21,12 @@ export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
const { activeFiles, currentView, setCurrentView } = useFileContext();
const { state } = useFileState();
const { actions } = useFileActions();
const { currentMode: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setMode;
const activeFiles = state.files.ids;
const {
previewFile,
pageEditorFunctions,
@@ -47,12 +53,12 @@ export default function Workbench() {
handleToolSelect('convert');
sessionStorage.removeItem('previousMode');
} else {
setCurrentView('fileEditor' as any);
setCurrentView('fileEditor');
}
};
const renderMainContent = () => {
if (!activeFiles[0]) {
if (activeFiles.length === 0) {
return (
<LandingPage
/>
@@ -69,11 +75,11 @@ export default function Workbench() {
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolKey && {
onOpenPageEditor: (file) => {
setCurrentView("pageEditor" as any);
setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {
filesToMerge.forEach(addToActiveFiles);
setCurrentView("viewer" as any);
setCurrentView("viewer");
}
})}
/>
@@ -142,7 +148,7 @@ export default function Workbench() {
{/* Top Controls */}
<TopControls
currentView={currentView}
setCurrentView={setCurrentView as any /* FIX ME */}
setCurrentView={setCurrentView}
selectedToolKey={selectedToolKey}
/>

View File

@@ -1,5 +1,7 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
interface DragDropItem {
@@ -12,19 +14,9 @@ interface DragDropGridProps<T extends DragDropItem> {
selectedItems: number[];
selectionMode: boolean;
isAnimating: boolean;
onDragStart: (pageNumber: number) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void;
onEndZoneDragEnter: () => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
draggedItem: number | null;
dropTarget: number | 'end' | null;
multiItemDrag: {pageNumbers: number[], count: number} | null;
dragPosition: {x: number, y: number} | null;
}
const DragDropGrid = <T extends DragDropItem>({
@@ -32,104 +24,129 @@ const DragDropGrid = <T extends DragDropItem>({
selectedItems,
selectionMode,
isAnimating,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onEndZoneDragEnter,
onReorderPages,
renderItem,
renderSplitMarker,
draggedItem,
dropTarget,
multiItemDrag,
dragPosition,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Global drag cleanup
const containerRef = useRef<HTMLDivElement>(null);
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const ITEM_WIDTH = 320; // 20rem (page width)
const ITEM_GAP = 24; // 1.5rem gap between items
const ITEM_HEIGHT = 340; // 20rem + gap
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
if (!containerRef.current) return 4; // Default fallback
const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4; // Container not measured yet
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row
}, []);
// Update items per row when container resizes
useEffect(() => {
const handleGlobalDragEnd = () => {
onDragEnd();
const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow();
setItemsPerRow(newItemsPerRow);
};
const handleGlobalDrop = (e: DragEvent) => {
e.preventDefault();
};
if (draggedItem) {
document.addEventListener('dragend', handleGlobalDragEnd);
document.addEventListener('drop', handleGlobalDrop);
// Initial calculation
updateLayout();
// Listen for window resize
window.addEventListener('resize', updateLayout);
// Use ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(updateLayout);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
document.removeEventListener('dragend', handleGlobalDragEnd);
document.removeEventListener('drop', handleGlobalDrop);
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [draggedItem, onDragEnd]);
}, [calculateItemsPerRow]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
estimateSize: () => ITEM_HEIGHT,
overscan: OVERSCAN,
});
return (
<Box>
<Box
ref={containerRef}
style={{
// Basic container styles
width: '100%',
height: '100%',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
paddingBottom: '100px',
// Performance optimizations for smooth scrolling
willChange: 'scroll-position',
transform: 'translateZ(0)', // Force hardware acceleration
backfaceVisibility: 'hidden',
// Use containment for better rendering performance
contain: 'layout style paint',
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{items.map((item, index) => (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
{/* Item */}
{renderItem(item, index, itemRefs)}
</React.Fragment>
))}
{/* End drop zone */}
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
<div
data-drop-zone="end"
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
dropTarget === 'end'
? 'ring-2 ring-green-500 bg-green-50'
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
}`}
style={{ borderRadius: '12px' }}
onDragOver={onDragOver}
onDragEnter={onEndZoneDragEnter}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, 'end')}
>
<div className="text-gray-500 text-sm text-center font-medium">
Drop here to<br />move to end
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
const rowItems = items.slice(startIndex, endIndex);
return (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
style={{
display: 'flex',
gap: '1.5rem',
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Multi-item drag indicator */}
{multiItemDrag && dragPosition && (
<div
className={styles.multiDragIndicator}
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiItemDrag.count} items
</div>
)}
</Box>
);
};

View File

@@ -1,14 +1,12 @@
import React, { useState } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import HistoryIcon from '@mui/icons-material/History';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
import FileOperationHistory from '../history/FileOperationHistory';
import { useFileContext } from '../../contexts/FileContext';
interface FileItem {
@@ -26,20 +24,11 @@ interface FileThumbnailProps {
totalFiles: number;
selectedFiles: string[];
selectionMode: boolean;
draggedFile: string | null;
dropTarget: string | null;
isAnimating: boolean;
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (fileId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (fileId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, fileId: string) => void;
onToggleFile: (fileId: string) => void;
onDeleteFile: (fileId: string) => void;
onViewFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
toolMode?: boolean;
isSupported?: boolean;
}
@@ -50,26 +39,20 @@ const FileThumbnail = ({
totalFiles,
selectedFiles,
selectionMode,
draggedFile,
dropTarget,
isAnimating,
fileRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onToggleFile,
onDeleteFile,
onViewFile,
onSetStatus,
onReorderFiles,
toolMode = false,
isSupported = true,
}: FileThumbnailProps) => {
const { t } = useTranslation();
const [showHistory, setShowHistory] = useState(false);
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// Drag and drop state
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
// Find the actual File object that corresponds to this FileItem
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
@@ -82,15 +65,57 @@ const FileThumbnail = ({
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id] // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as string;
const selectedFileIds = sourceData.selectedFiles as string[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
return (
<div
ref={(el) => {
if (el) {
fileRefs.current.set(file.id, el);
} else {
fileRefs.current.delete(file.id);
}
}}
ref={fileElementRef}
data-file-id={file.id}
data-testid="file-thumbnail"
className={`
@@ -109,26 +134,12 @@ const FileThumbnail = ({
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
${isDragging ? 'opacity-50 scale-95' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
opacity: isSupported ? 1 : 0.5,
opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
draggable
onDragStart={() => onDragStart(file.id)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(file.id)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, file.id)}
>
{selectionMode && (
<div
@@ -187,6 +198,12 @@ const FileThumbnail = ({
<img
src={file.thumbnail}
alt={file.name}
draggable={false}
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = 'none';
}}
style={{
maxWidth: '100%',
maxHeight: '100%',
@@ -196,20 +213,22 @@ const FileThumbnail = ({
/>
</div>
{/* Page count badge */}
<Badge
size="sm"
variant="filled"
color="blue"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 3,
}}
>
{file.pageCount} pages
</Badge>
{/* Page count badge - only show for PDFs */}
{file.pageCount > 0 && (
<Badge
size="sm"
variant="filled"
color="blue"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 3,
}}
>
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
</Badge>
)}
{/* Unsupported badge */}
{!isSupported && (
@@ -273,40 +292,6 @@ const FileThumbnail = ({
whiteSpace: 'nowrap'
}}
>
{!toolMode && isSupported && (
<>
<Tooltip label="View File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onViewFile(file.id);
onSetStatus(`Opened ${file.name}`);
}}
>
<VisibilityIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</>
)}
<Tooltip label="View History">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
setShowHistory(true);
onSetStatus(`Viewing history for ${file.name}`);
}}
>
<HistoryIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
{actualFile && (
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
@@ -372,20 +357,6 @@ const FileThumbnail = ({
</Text>
</div>
{/* History Modal */}
<Modal
opened={showHistory}
onClose={() => setShowHistory(false)}
title={`Operation History - ${file.name}`}
size="lg"
scrollAreaComponent={'div' as any}
>
<FileOperationHistory
fileId={file.name}
showOnlyApplied={true}
maxHeight={500}
/>
</Modal>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -6,20 +6,13 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo';
import { useFileState } from '../../contexts/FileContext';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import styles from './PageEditor.module.css';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
// Ensure PDF.js worker is available
if (!GlobalWorkerOptions.workerSrc) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
console.log('📸 PageThumbnail: Set PDF.js worker source to /pdf.worker.js');
} else {
console.log('📸 PageThumbnail: PDF.js worker source already set to', GlobalWorkerOptions.workerSrc);
}
interface PageThumbnailProps {
page: PDFPage;
@@ -28,22 +21,15 @@ interface PageThumbnailProps {
originalFile?: File; // For lazy thumbnail generation
selectedPages: number[];
selectionMode: boolean;
draggedPage: number | null;
dropTarget: number | 'end' | null;
movingPage: number | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (pageNumber: number) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, pageNumber: number) => void;
onTogglePage: (pageNumber: number) => void;
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
onExecuteCommand: (command: Command) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (pageNumber: number | null) => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
RotatePagesCommand: typeof RotatePagesCommand;
DeletePagesCommand: typeof DeletePagesCommand;
ToggleSplitCommand: typeof ToggleSplitCommand;
@@ -58,22 +44,15 @@ const PageThumbnail = React.memo(({
originalFile,
selectedPages,
selectionMode,
draggedPage,
dropTarget,
movingPage,
isAnimating,
pageRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onTogglePage,
onAnimateReorder,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
onReorderPages,
RotatePagesCommand,
DeletePagesCommand,
ToggleSplitCommand,
@@ -81,51 +60,122 @@ const PageThumbnail = React.memo(({
setPdfDocument,
}: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement>(null);
const { state, selectors } = useFileState();
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
// Update thumbnail URL when page prop changes
// Update thumbnail URL when page prop changes - prevent redundant updates
useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
setThumbnailUrl(page.thumbnail);
}
}, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]);
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
// Request thumbnail generation if not available (optimized for performance)
useEffect(() => {
if (thumbnailUrl) {
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
return; // Skip if we already have a thumbnail
if (thumbnailUrl || !originalFile) {
return; // Skip if we already have a thumbnail or no original file
}
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
// Check cache first without async call
const cachedThumbnail = getThumbnailFromCache(page.id);
if (cachedThumbnail) {
setThumbnailUrl(cachedThumbnail);
return;
}
const handleThumbnailReady = (event: CustomEvent) => {
const { pageNumber, thumbnail, pageId } = event.detail;
console.log(`📸 PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`);
let cancelled = false;
if (pageNumber === page.pageNumber && pageId === page.id) {
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
setThumbnailUrl(thumbnail);
const loadThumbnail = async () => {
try {
const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber);
// Only update if component is still mounted and we got a result
if (!cancelled && thumbnail) {
setThumbnailUrl(thumbnail);
}
} catch (error) {
if (!cancelled) {
console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error);
}
}
};
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
loadThumbnail();
// Cleanup function to prevent state updates after unmount
return () => {
console.log(`📸 PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`);
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
cancelled = true;
};
}, [page.pageNumber, page.id, thumbnailUrl]);
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
// Register this component with pageRefs for animations
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
if (element) {
pageRefs.current.set(page.id, element);
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
pageNumber: page.pageNumber,
pageId: page.id,
selectedPages: selectionMode && selectedPages.includes(page.pageNumber)
? selectedPages
: [page.pageNumber]
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ location }) => {
setIsDragging(false);
if (location.current.dropTargets.length === 0) {
return;
}
const dropTarget = location.current.dropTargets[0];
const targetData = dropTarget.data;
if (targetData.type === 'page') {
const targetPageNumber = targetData.pageNumber as number;
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
if (targetIndex !== -1) {
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
? selectedPages
: undefined;
onReorderPages(page.pageNumber, targetIndex, pagesToMove);
}
}
}
});
element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'page',
pageNumber: page.pageNumber
}),
onDrop: ({ source }) => {}
});
(element as any).__dragCleanup = () => {
dragCleanup();
dropCleanup();
};
} else {
pageRefs.current.delete(page.id);
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
(dragElementRef.current as any).__dragCleanup();
}
}
}, [page.id, pageRefs]);
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
return (
<div
@@ -147,25 +197,13 @@ const PageThumbnail = React.memo(({
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable
onDragStart={() => onDragStart(page.pageNumber)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(page.pageNumber)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.pageNumber)}
draggable={false}
>
{selectionMode && (
<div
@@ -189,7 +227,6 @@ const PageThumbnail = React.memo(({
e.stopPropagation();
}}
onClick={(e) => {
console.log('📸 Checkbox clicked for page', page.pageNumber);
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
@@ -204,7 +241,7 @@ const PageThumbnail = React.memo(({
</div>
)}
<div className="page-container w-[90%] h-[90%]">
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
style={{
width: '100%',
@@ -222,6 +259,7 @@ const PageThumbnail = React.memo(({
<img
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
style={{
width: '100%',
height: '100%',
@@ -231,11 +269,6 @@ const PageThumbnail = React.memo(({
transition: 'transform 0.3s ease-in-out'
}}
/>
) : isLoadingThumbnail ? (
<div style={{ textAlign: 'center' }}>
<Loader size="sm" />
<Text size="xs" c="dimmed" mt={4}>Loading...</Text>
</div>
) : (
<div style={{ textAlign: 'center' }}>
<Text size="lg" c="dimmed">📄</Text>
@@ -408,30 +441,25 @@ const PageThumbnail = React.memo(({
)}
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
</div>
);
}, (prevProps, nextProps) => {
// Helper for shallow array comparison
const arraysEqual = (a: number[], b: number[]) => {
return a.length === b.length && a.every((val, i) => val === b[i]);
};
// Only re-render if essential props change
return (
prevProps.page.id === nextProps.page.id &&
prevProps.page.pageNumber === nextProps.page.pageNumber &&
prevProps.page.rotation === nextProps.page.rotation &&
prevProps.page.thumbnail === nextProps.page.thumbnail &&
prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
// Shallow compare selectedPages array for better stability
(prevProps.selectedPages === nextProps.selectedPages ||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
prevProps.selectionMode === nextProps.selectionMode &&
prevProps.draggedPage === nextProps.draggedPage &&
prevProps.dropTarget === nextProps.dropTarget &&
prevProps.movingPage === nextProps.movingPage &&
prevProps.isAnimating === nextProps.isAnimating
);

View File

@@ -6,12 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { FileWithUrl } from "../../types/file";
import { FileRecord } from "../../types/fileContext";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: FileWithUrl;
file: File;
record?: FileRecord;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
@@ -21,9 +22,12 @@ interface FileCardProps {
isSupported?: boolean; // Whether the file format is supported by the current tool
}
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
return (
@@ -173,7 +177,7 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
{record?.id && (
<Badge
color="green"
variant="light"

View File

@@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "./FileCard";
import { FileWithUrl } from "../../types/file";
import { FileRecord } from "../../types/fileContext";
interface FileGridProps {
files: FileWithUrl[];
files: Array<{ file: File; record?: FileRecord }>;
onRemove?: (index: number) => void;
onDoubleClick?: (file: FileWithUrl) => void;
onView?: (file: FileWithUrl) => void;
onEdit?: (file: FileWithUrl) => void;
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
onView?: (item: { file: File; record?: FileRecord }) => void;
onEdit?: (item: { file: File; record?: FileRecord }) => void;
onSelect?: (fileId: string) => void;
selectedFiles?: string[];
showSearch?: boolean;
@@ -46,19 +46,19 @@ const FileGrid = ({
const [sortBy, setSortBy] = useState<SortOption>('date');
// Filter files based on search term
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
const filteredFiles = files.filter(item =>
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Sort files
const sortedFiles = [...filteredFiles].sort((a, b) => {
switch (sortBy) {
case 'date':
return (b.lastModified || 0) - (a.lastModified || 0);
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
case 'name':
return a.name.localeCompare(b.name);
return a.file.name.localeCompare(b.file.name);
case 'size':
return (b.size || 0) - (a.size || 0);
return (b.file.size || 0) - (a.file.size || 0);
default:
return 0;
}
@@ -122,18 +122,19 @@ const FileGrid = ({
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
>
{displayFiles.map((file, idx) => {
const fileId = file.id || file.name;
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
const supported = isFileSupported ? isFileSupported(file.name) : true;
{displayFiles.map((item, idx) => {
const fileId = item.record?.id || item.file.name;
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
<FileCard
key={fileId + idx}
file={file}
file={item.file}
record={item.record}
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
onView={onView && supported ? () => onView(file) : undefined}
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
onView={onView && supported ? () => onView(item) : undefined}
onEdit={onEdit && supported ? () => onEdit(item) : undefined}
isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
isSupported={supported}

View File

@@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next';
interface FilePickerModalProps {
opened: boolean;
onClose: () => void;
storedFiles: any[]; // Files from storage (FileWithUrl format)
storedFiles: any[]; // Files from storage (various formats supported)
onSelectFiles: (selectedFiles: File[]) => void;
}
@@ -48,7 +48,7 @@ const FilePickerModal = ({
};
const selectAll = () => {
setSelectedFileIds(storedFiles.map(f => f.id || f.name));
setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
};
const selectNone = () => {
@@ -57,7 +57,7 @@ const FilePickerModal = ({
const handleConfirm = async () => {
const selectedFiles = storedFiles.filter(f =>
selectedFileIds.includes(f.id || f.name)
selectedFileIds.includes(f.id)
);
// Convert stored files to File objects
@@ -154,7 +154,7 @@ const FilePickerModal = ({
<ScrollArea.Autosize mah={400}>
<SimpleGrid cols={2} spacing="md">
{storedFiles.map((file) => {
const fileId = file.id || file.name;
const fileId = file.id;
const isSelected = selectedFileIds.includes(fileId);
return (

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Box } from '@mantine/core';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
import DocumentThumbnail from './filePreview/DocumentThumbnail';
import DocumentStack from './filePreview/DocumentStack';
import HoverOverlay from './filePreview/HoverOverlay';
@@ -8,7 +8,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
export interface FilePreviewProps {
// Core file data
file: File | FileWithUrl | null;
file: File | FileMetadata | null;
thumbnail?: string | null;
// Optional features
@@ -21,7 +21,7 @@ export interface FilePreviewProps {
isAnimating?: boolean;
// Event handlers
onFileClick?: (file: File | FileWithUrl | null) => void;
onFileClick?: (file: File | FileMetadata | null) => void;
onPrevious?: () => void;
onNext?: () => void;
}

View File

@@ -33,7 +33,7 @@ const LandingPage = () => {
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["*/*"] as any}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95vh]"
style={{
@@ -125,7 +125,7 @@ const LandingPage = () => {
ref={fileInputRef}
type="file"
multiple
accept="*/*"
accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
import { useNavigationGuard } from '../../contexts/NavigationContext';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
@@ -11,13 +11,13 @@ const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const {
showNavigationWarning,
const {
showNavigationWarning,
hasUnsavedChanges,
confirmNavigation,
cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useFileContext();
} = useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
@@ -10,50 +10,18 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
import { ModeType } from '../../contexts/NavigationContext';
// This will be created inside the component to access switchingTo
const createViewOptions = (switchingTo: string | null) => [
{
label: (
<Group gap={5}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
</Group>
),
value: "viewer",
},
{
label: (
<Group gap={4}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
</Group>
),
value: "pageEditor",
},
{
label: (
<Group gap={4}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
</Group>
),
value: "fileEditor",
},
];
// Stable view option objects that don't recreate on every render
const VIEW_OPTIONS_BASE = [
{ value: "viewer", icon: VisibilityIcon },
{ value: "pageEditor", icon: EditNoteIcon },
{ value: "fileEditor", icon: FolderIcon },
] as const;
interface TopControlsProps {
currentView: string;
setCurrentView: (view: string) => void;
currentView: ModeType;
setCurrentView: (view: ModeType) => void;
selectedToolKey?: string | null;
}
@@ -68,6 +36,9 @@ const TopControls = ({
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
// Guard against redundant changes
if (view === currentView) return;
// Show immediate feedback
setSwitchingTo(view);
@@ -75,13 +46,28 @@ const TopControls = ({
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(view);
setCurrentView(view as ModeType);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
}, [setCurrentView]);
}, [setCurrentView, currentView]);
// Memoize the SegmentedControl data with stable references
const viewOptions = useMemo(() =>
VIEW_OPTIONS_BASE.map(option => ({
value: option.value,
label: (
<Group gap={option.value === "viewer" ? 5 : 4}>
{switchingTo === option.value ? (
<Loader size="xs" />
) : (
<option.icon fontSize="small" />
)}
</Group>
)
})), [switchingTo]);
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
@@ -117,7 +103,7 @@ const TopControls = ({
{!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={createViewOptions(switchingTo)}
data={viewOptions}
value={currentView}
onChange={handleViewChange}
color="blue"

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FileWithUrl } from '../../../types/file';
import { FileMetadata } from '../../../types/file';
export interface DocumentThumbnailProps {
file: File | FileWithUrl | null;
file: File | FileMetadata | null;
thumbnail?: string | null;
style?: React.CSSProperties;
onClick?: () => void;

View File

@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
import { useFileContext } from "../../../contexts/FileContext";
import { useFileSelection } from "../../../contexts/FileContext";
import { useFileState } from "../../../contexts/FileContext";
import { detectFileExtension } from "../../../utils/fileUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings";
@@ -41,8 +41,9 @@ const ConvertSettings = ({
const { t } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { setSelectedFiles } = useFileSelectionActions();
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
const { setSelectedFiles } = useFileSelection();
const { state, selectors } = useFileState();
const activeFiles = state.files.ids;
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
@@ -85,9 +86,9 @@ const ConvertSettings = ({
}
return baseOptions;
}, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]);
}, [parameters.fromExtension, endpointStatus]);
// Enhanced TO options with endpoint availability
// Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
@@ -96,7 +97,7 @@ const ConvertSettings = ({
...option,
enabled: isConversionAvailable(parameters.fromExtension, option.value)
}));
}, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]);
}, [parameters.fromExtension, endpointStatus]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -127,7 +128,8 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
return activeFiles.filter(file => {
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
if (extension === 'any') {
@@ -141,9 +143,21 @@ const ConvertSettings = ({
};
const updateFileSelection = (files: File[]) => {
setSelectedFiles(files);
const fileIds = files.map(file => (file as any).id || file.name);
setContextSelectedFiles(fileIds);
// Map File objects to their actual IDs in FileContext
const fileIds = files.map(file => {
// Find the file ID by matching file properties
const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id))
.find(record =>
record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified
);
return fileRecord?.id;
}).filter((id): id is string => id !== undefined); // Type guard to ensure only strings
setSelectedFiles(fileIds);
};
const handleFromExtensionChange = (value: string) => {

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
import { useTranslation } from "react-i18next";
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
@@ -13,10 +13,9 @@ import CloseIcon from "@mui/icons-material/Close";
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileContext } from "../../contexts/FileContext";
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
// Lazy loading page image component
interface LazyPageImageProps {
@@ -150,7 +149,15 @@ const Viewer = ({
const theme = useMantineTheme();
// Get current file from FileContext
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
const { selectors } = useFileState();
const { actions } = useFileActions();
const currentFile = useCurrentFile();
const getCurrentFile = () => currentFile.file;
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
const clearAllFiles = actions.clearAllFiles;
const addFiles = actions.addFiles;
const activeFiles = selectors.getFiles();
// Tab management for multiple files
const [activeTab, setActiveTab] = useState<string>("0");
@@ -171,6 +178,10 @@ const Viewer = ({
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
// Memoize setPageRef to prevent infinite re-renders
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
pageRefs.current[index] = ref;
}, []);
// Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl(activeFiles[0]);
@@ -385,7 +396,7 @@ const Viewer = ({
throw new Error('No valid PDF source available');
}
const pdf = await getDocument(pdfData).promise;
const pdf = await pdfWorkerManager.createDocument(pdfData);
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
@@ -406,6 +417,11 @@ const Viewer = ({
cancelled = true;
// Stop any ongoing preloading
preloadingRef.current = false;
// Cleanup PDF document using worker manager
if (pdfDocRef.current) {
pdfWorkerManager.destroyDocument(pdfDocRef.current);
pdfDocRef.current = null;
}
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
@@ -461,7 +477,7 @@ const Viewer = ({
>
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
<Tabs.List>
{activeFiles.map((file, index) => (
{activeFiles.map((file: any, index: number) => (
<Tabs.Tab key={index} value={index.toString()}>
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
</Tabs.Tab>
@@ -494,7 +510,7 @@ const Viewer = ({
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
setPageRef={setPageRef}
/>
{i * 2 + 1 < numPages && (
<LazyPageImage
@@ -504,7 +520,7 @@ const Viewer = ({
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
setPageRef={setPageRef}
/>
)}
</Group>
@@ -518,7 +534,7 @@ const Viewer = ({
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
setPageRef={setPageRef}
/>
))}
</Stack>