Stirling 2.0 (#3928)

# Description of Changes

<!--

File context for managing files between tools and views
Optimisation for large files
Updated Split to work with new file system and match Matts stepped
design closer

-->

---

## 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: Anthony Stirling <[email protected]>
This commit is contained in:
Reece Browne
2025-07-16 17:53:50 +01:00
committed by GitHub
co-authored by Anthony Stirling
parent 584e2ecee7
commit 922bbc9076
66 changed files with 8728 additions and 2519 deletions
@@ -0,0 +1,858 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService';
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;
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
}: FileEditorProps) => {
const { t } = useTranslation();
// Get file context
const fileContext = useFileContext();
const {
activeFiles,
processedFiles,
selectedFileIds,
setSelectedFiles: setContextSelectedFiles,
isProcessing,
addFiles,
removeFiles,
setCurrentView,
recordOperation,
markOperationApplied
} = fileContext;
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
React.useEffect(() => {
if (toolMode) {
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;
progress: number;
extractedCount: number;
totalFiles: number;
}>({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
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 : [];
const localSelectedFiles = files
.filter(file => {
const fileId = (file.file as any).id || file.name;
return safeSelectedFileIds.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);
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,
};
}, []);
// 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,
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]);
// Process uploaded files using context
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setError(null);
try {
const allExtractedFiles: File[] = [];
const errors: string[] = [];
for (const file of uploadedFiles) {
if (file.type === 'application/pdf') {
// Handle PDF files normally
allExtractedFiles.push(file);
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
// Handle ZIP files
try {
// Validate ZIP file first
const validation = await zipFileService.validateZipFile(file);
if (!validation.isValid) {
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
continue;
}
// Extract PDF files from ZIP
setZipExtractionProgress({
isExtracting: true,
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({
isExtracting: true,
currentFile: progress.currentFile,
progress: progress.progress,
extractedCount: progress.extractedCount,
totalFiles: progress.totalFiles
});
});
// Reset extraction progress
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'convert',
timestamp: Date.now(),
fileIds: extractionResult.extractedFiles.map(f => f.name),
status: 'pending',
metadata: {
originalFileName: file.name,
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
fileSize: file.size,
parameters: {
extractionType: 'zip',
extractedCount: extractionResult.extractedCount,
totalFiles: extractionResult.totalFiles
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
} catch (zipError) {
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
} else {
errors.push(`Unsupported file type: ${file.name} (${file.type})`);
}
}
// Show any errors
if (errors.length > 0) {
setError(errors.join('\n'));
}
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Record upload operations for PDF files
for (const file of allExtractedFiles) {
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'upload',
timestamp: Date.now(),
fileIds: [file.name],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
uploadMethod: 'drag-drop'
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
}
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`);
}
} catch (err) {
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,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
}, [addFiles, recordOperation, markOperationApplied]);
const selectAll = useCallback(() => {
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
}, [files, setContextSelectedFiles]);
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
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);
});
// Remove all files from context but keep in storage
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
// Clear selections
setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
const toggleFile = useCallback((fileId: string) => {
const targetFile = files.find(f => f.id === fileId);
if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name;
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]);
}
}
} 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);
}
}
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setContextSelectedFiles([]);
}
return newMode;
});
}, [setContextSelectedFiles]);
// Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId);
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) {
setMultiFileDrag({
fileIds: localSelectedFiles,
count: localSelectedFiles.length
});
} else {
setMultiFileDrag(null);
}
}, [selectionMode, localSelectedFiles]);
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');
return;
}
setDropTarget(null);
}, [draggedFile, multiFileDrag]);
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 && localSelectedFiles.includes(draggedFile)
? localSelectedFiles
: [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;
});
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
setDropTarget('end');
}
}, [draggedFile]);
// 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);
// Record close operation
const fileName = file.file.name;
const fileId = (file.file as any).id || fileName;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [fileName],
status: 'pending',
metadata: {
originalFileName: fileName,
fileSize: file.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);
// Remove from context selections
setContextSelectedFiles(prev => {
const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== fileId);
});
// Mark operation as applied
markOperationApplied(fileName, operationId);
} else {
console.log('File not found for fileId:', fileId);
}
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
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);
}
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
if (startIndex === -1) return;
const filesToMerge = files.slice(startIndex).map(f => f.file);
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [files, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, 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]);
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 (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md" pt="xl">
<Group mb="md">
{showBulkActions && !toolMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
<Button onClick={closeAllFiles} variant="light" color="orange">
Close All
</Button>
</>
)}
{/* Load from storage and upload buttons */}
{showUpload && (
<>
<Button
variant="outline"
color="blue"
onClick={() => setShowFilePickerModal(true)}
>
Load from Storage
</Button>
<Dropzone
onDrop={handleFileUpload}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}
>
<Button variant="outline" color="green">
Upload Files
</Button>
</Dropzone>
</>
)}
</Group>
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
<Text c="dimmed">No files loaded</Text>
<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) ? (
<Box>
<SkeletonLoader type="controls" />
{/* ZIP Extraction Progress */}
{zipExtractionProgress.isExtracting && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
</Group>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.currentFile || 'Processing files...'}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
</Text>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(zipExtractionProgress.progress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-orange-6)',
transition: 'width 0.3s ease'
}} />
</div>
</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={localSelectedFiles}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile}
dropTarget={dropTarget}
multiItemDrag={multiFileDrag}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
<FileThumbnail
file={file}
index={index}
totalFiles={files.length}
selectedFiles={localSelectedFiles}
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}
onMergeFromHere={handleMergeFromHere}
onSplitFile={handleSplitFile}
onSetStatus={setStatus}
toolMode={toolMode}
/>
)}
renderSplitMarker={(file, index) => (
<div
style={{
width: '2px',
height: '24rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
)}
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
allowMultiple={true}
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
)}
</Box>
);
};
export default FileEditor;
@@ -6,9 +6,9 @@ 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 { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
import { fileStorage } from "../../services/fileStorage";
interface FileCardProps {
file: FileWithUrl;
@@ -1,440 +0,0 @@
import React, { useState, useEffect } from "react";
import { Box, Flex, Text, Notification, Button, Group } from "@mantine/core";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import { GlobalWorkerOptions } from "pdfjs-dist";
import { StorageStats } from "../../services/fileStorage";
import { FileWithUrl, defaultStorageConfig, initializeStorageConfig, StorageConfig } from "../../types/file";
// Refactored imports
import { fileOperationsService } from "../../services/fileOperationsService";
import { checkStorageWarnings } from "../../utils/storageUtils";
import StorageStatsCard from "./StorageStatsCard";
import FileCard from "./FileCard";
import FileUploadSelector from "../shared/FileUploadSelector";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
interface FileManagerProps {
files: FileWithUrl[];
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
allowMultiple?: boolean;
setCurrentView?: (view: string) => void;
onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void;
onOpenPageEditor?: (selectedFiles?: FileWithUrl[]) => void;
onLoadFileToActive?: (file: File) => void;
}
const FileManager = ({
files = [],
setFiles,
allowMultiple = true,
setCurrentView,
onOpenFileEditor,
onOpenPageEditor,
onLoadFileToActive,
}: FileManagerProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const [filesLoaded, setFilesLoaded] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [storageConfig, setStorageConfig] = useState<StorageConfig>(defaultStorageConfig);
// Extract operations from service for cleaner code
const {
loadStorageStats,
forceReloadFiles,
loadExistingFiles,
uploadFiles,
removeFile,
clearAllFiles,
createBlobUrlForFile,
checkForPurge,
updateStorageStatsIncremental
} = fileOperationsService;
// Add CSS for spinner animation
useEffect(() => {
if (!document.querySelector('#spinner-animation')) {
const style = document.createElement('style');
style.id = 'spinner-animation';
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
}, []);
// Load existing files from IndexedDB on mount
useEffect(() => {
if (!filesLoaded) {
handleLoadExistingFiles();
}
}, [filesLoaded]);
// Initialize storage configuration on mount
useEffect(() => {
const initStorage = async () => {
try {
const config = await initializeStorageConfig();
setStorageConfig(config);
console.log('Initialized storage config:', config);
} catch (error) {
console.warn('Failed to initialize storage config, using defaults:', error);
}
};
initStorage();
}, []);
// Load storage stats and set up periodic updates
useEffect(() => {
handleLoadStorageStats();
const interval = setInterval(async () => {
await handleLoadStorageStats();
await handleCheckForPurge();
}, 10000); // Update every 10 seconds
return () => clearInterval(interval);
}, []);
// Sync UI with IndexedDB whenever storage stats change
useEffect(() => {
const syncWithStorage = async () => {
if (storageStats && filesLoaded) {
// If file counts don't match, force reload
if (storageStats.fileCount !== files.length) {
console.warn('File count mismatch: storage has', storageStats.fileCount, 'but UI shows', files.length, '- forcing reload');
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
}
}
};
syncWithStorage();
}, [storageStats, filesLoaded, files.length]);
// Handlers using extracted operations
const handleLoadStorageStats = async () => {
const stats = await loadStorageStats();
if (stats) {
setStorageStats(stats);
// Check for storage warnings
const warning = checkStorageWarnings(stats);
if (warning) {
setNotification(warning);
}
}
};
const handleLoadExistingFiles = async () => {
try {
const loadedFiles = await loadExistingFiles(filesLoaded, files);
setFiles(loadedFiles);
setFilesLoaded(true);
} catch (error) {
console.error('Failed to load existing files:', error);
setFilesLoaded(true);
}
};
const handleCheckForPurge = async () => {
try {
const isPurged = await checkForPurge(files);
if (isPurged) {
console.warn('IndexedDB purge detected - forcing UI reload');
setNotification(t("fileManager.storageCleared", "Browser cleared storage. Files have been removed. Please re-upload."));
const reloadedFiles = await forceReloadFiles();
setFiles(reloadedFiles);
setFilesLoaded(true);
}
} catch (error) {
console.error('Error checking for purge:', error);
}
};
const validateStorageLimits = (filesToUpload: File[]): { valid: boolean; error?: string } => {
// Check individual file sizes
for (const file of filesToUpload) {
if (file.size > storageConfig.maxFileSize) {
const maxSizeMB = Math.round(storageConfig.maxFileSize / (1024 * 1024));
return {
valid: false,
error: `${t("storage.fileTooLarge", "File too large. Maximum size per file is")} ${maxSizeMB}MB`
};
}
}
// Check total storage capacity
if (storageStats) {
const totalNewSize = filesToUpload.reduce((sum, file) => sum + file.size, 0);
const projectedUsage = storageStats.totalSize + totalNewSize;
if (projectedUsage > storageConfig.maxTotalStorage) {
return {
valid: false,
error: t("storage.storageQuotaExceeded", "Storage quota exceeded. Please remove some files before uploading more.")
};
}
}
return { valid: true };
};
const handleDrop = async (uploadedFiles: File[]) => {
setLoading(true);
try {
// Validate storage limits before uploading
const validation = validateStorageLimits(uploadedFiles);
if (!validation.valid) {
setNotification(validation.error);
setLoading(false);
return;
}
const newFiles = await uploadFiles(uploadedFiles, storageConfig.useIndexedDB);
// Update files state
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles));
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'add', newFiles);
setStorageStats(updatedStats);
// Check for storage warnings
const warning = checkStorageWarnings(updatedStats);
if (warning) {
setNotification(warning);
}
}
} catch (error) {
console.error('Error handling file drop:', error);
setNotification(t("fileManager.uploadError", "Failed to upload some files."));
} finally {
setLoading(false);
}
};
const handleRemoveFile = async (index: number) => {
const file = files[index];
try {
await removeFile(file);
// Update storage stats incrementally
if (storageStats) {
const updatedStats = updateStorageStatsIncremental(storageStats, 'remove', [file]);
setStorageStats(updatedStats);
}
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
} catch (error) {
console.error('Failed to remove file:', error);
}
};
const handleClearAll = async () => {
try {
await clearAllFiles(files);
// Reset storage stats
if (storageStats) {
const clearedStats = updateStorageStatsIncremental(storageStats, 'clear');
setStorageStats(clearedStats);
}
setFiles([]);
} catch (error) {
console.error('Failed to clear all files:', error);
}
};
const handleReloadFiles = () => {
setFilesLoaded(false);
setFiles([]);
};
const handleFileDoubleClick = async (file: FileWithUrl) => {
try {
// Reconstruct File object from storage and add to active files
if (onLoadFileToActive) {
const reconstructedFile = await reconstructFileFromStorage(file);
onLoadFileToActive(reconstructedFile);
setCurrentView && setCurrentView("viewer");
}
} catch (error) {
console.error('Failed to load file to active set:', error);
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
}
};
const handleFileView = async (file: FileWithUrl) => {
try {
// Reconstruct File object from storage and add to active files
if (onLoadFileToActive) {
const reconstructedFile = await reconstructFileFromStorage(file);
onLoadFileToActive(reconstructedFile);
setCurrentView && setCurrentView("viewer");
}
} catch (error) {
console.error('Failed to load file to active set:', error);
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
}
};
const reconstructFileFromStorage = async (fileWithUrl: FileWithUrl): Promise<File> => {
// If it's already a regular file, return it
if (fileWithUrl instanceof File) {
return fileWithUrl;
}
// Reconstruct from IndexedDB
const arrayBuffer = await createBlobUrlForFile(fileWithUrl);
if (typeof arrayBuffer === 'string') {
// createBlobUrlForFile returned a blob URL, we need the actual data
const response = await fetch(arrayBuffer);
const data = await response.arrayBuffer();
return new File([data], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
} else {
return new File([arrayBuffer], fileWithUrl.name, {
type: fileWithUrl.type || 'application/pdf',
lastModified: fileWithUrl.lastModified || Date.now()
});
}
};
const handleFileEdit = (file: FileWithUrl) => {
if (onOpenFileEditor) {
onOpenFileEditor([file]);
}
};
const toggleFileSelection = (fileId: string) => {
setSelectedFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
};
const handleOpenSelectedInEditor = () => {
if (onOpenFileEditor && selectedFiles.length > 0) {
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
onOpenFileEditor(selected);
}
};
const handleOpenSelectedInPageEditor = () => {
if (onOpenPageEditor && selectedFiles.length > 0) {
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
onOpenPageEditor(selected);
}
};
return (
<div style={{
width: "100%",
justifyContent: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: "3rem"
}}>
{/* File upload is now handled by FileUploadSelector when no files exist */}
{/* Storage Stats Card */}
<StorageStatsCard
storageStats={storageStats}
filesCount={files.length}
onClearAll={handleClearAll}
onReloadFiles={handleReloadFiles}
storageConfig={storageConfig}
/>
{/* Multi-selection controls */}
{selectedFiles.length > 0 && (
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm">
{selectedFiles.length} {t("fileManager.filesSelected", "files selected")}
</Text>
<Group>
<Button
size="xs"
variant="light"
onClick={() => setSelectedFiles([])}
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
<Button
size="xs"
color="orange"
onClick={handleOpenSelectedInEditor}
disabled={selectedFiles.length === 0}
>
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
<Button
size="xs"
color="blue"
onClick={handleOpenSelectedInPageEditor}
disabled={selectedFiles.length === 0}
>
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
</Group>
</Group>
</Box>
)}
<Flex
wrap="wrap"
gap="lg"
justify="flex-start"
style={{ width: "90%", marginTop: "1rem"}}
>
{files.map((file, idx) => (
<FileCard
key={file.id || file.name + idx}
file={file}
onRemove={() => handleRemoveFile(idx)}
onDoubleClick={() => handleFileDoubleClick(file)}
onView={() => handleFileView(file)}
onEdit={() => handleFileEdit(file)}
isSelected={selectedFiles.includes(file.id || file.name)}
onSelect={() => toggleFileSelection(file.id || file.name)}
/>
))}
</Flex>
{/* Notifications */}
{notification && (
<Notification
color="blue"
onClose={() => setNotification(null)}
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
>
{notification}
</Notification>
)}
</div>
);
};
export default FileManager;
@@ -0,0 +1,177 @@
import React from 'react';
import {
Stack,
Paper,
Text,
Badge,
Group,
Collapse,
Box,
ScrollArea,
Code,
Divider
} from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
interface FileOperationHistoryProps {
fileId: string;
showOnlyApplied?: boolean;
maxHeight?: number;
}
const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
fileId,
showOnlyApplied = false,
maxHeight = 400
}) => {
const { getFileHistory, getAppliedOperations } = useFileContext();
const history = getFileHistory(fileId);
const operations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const getOperationIcon = (type: string) => {
switch (type) {
case 'split': return '✂️';
case 'merge': return '🔗';
case 'compress': return '🗜️';
case 'rotate': return '🔄';
case 'delete': return '🗑️';
case 'move': return '↕️';
case 'insert': return '📄';
case 'upload': return '⬆️';
case 'add': return '';
case 'remove': return '';
case 'replace': return '🔄';
case 'convert': return '🔄';
default: return '⚙️';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'applied': return 'green';
case 'failed': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
};
const renderOperationDetails = (operation: FileOperation | PageOperation) => {
if ('metadata' in operation && operation.metadata) {
const { metadata } = operation;
return (
<Box mt="xs">
{metadata.parameters && (
<Text size="xs" c="dimmed">
Parameters: <Code>{JSON.stringify(metadata.parameters, null, 2)}</Code>
</Text>
)}
{metadata.originalFileName && (
<Text size="xs" c="dimmed">
Original file: {metadata.originalFileName}
</Text>
)}
{metadata.outputFileNames && (
<Text size="xs" c="dimmed">
Output files: {metadata.outputFileNames.join(', ')}
</Text>
)}
{metadata.fileSize && (
<Text size="xs" c="dimmed">
File size: {(metadata.fileSize / 1024 / 1024).toFixed(2)} MB
</Text>
)}
{metadata.pageCount && (
<Text size="xs" c="dimmed">
Pages: {metadata.pageCount}
</Text>
)}
{metadata.error && (
<Text size="xs" c="red">
Error: {metadata.error}
</Text>
)}
</Box>
);
}
return null;
};
if (!history || operations.length === 0) {
return (
<Paper p="md" withBorder>
<Text c="dimmed" ta="center">
{showOnlyApplied ? 'No applied operations found' : 'No operation history available'}
</Text>
</Paper>
);
}
return (
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Text fw={500}>
{showOnlyApplied ? 'Applied Operations' : 'Operation History'}
</Text>
<Badge variant="light" color="blue">
{operations.length} operations
</Badge>
</Group>
<ScrollArea h={maxHeight}>
<Stack gap="sm">
{operations.map((operation, index) => (
<Paper key={operation.id} p="sm" withBorder radius="sm" bg="gray.0">
<Group justify="space-between" align="start">
<Group gap="xs">
<Text span size="lg">
{getOperationIcon(operation.type)}
</Text>
<Box>
<Text fw={500} size="sm">
{operation.type.charAt(0).toUpperCase() + operation.type.slice(1)}
</Text>
<Text size="xs" c="dimmed">
{formatTimestamp(operation.timestamp)}
</Text>
</Box>
</Group>
<Badge
variant="filled"
color={getStatusColor(operation.status)}
size="sm"
>
{operation.status}
</Badge>
</Group>
{renderOperationDetails(operation)}
{index < operations.length - 1 && <Divider mt="sm" />}
</Paper>
))}
</Stack>
</ScrollArea>
{history && (
<Group justify="space-between" mt="sm" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
<Text size="xs" c="dimmed">
Created: {formatTimestamp(history.createdAt)}
</Text>
<Text size="xs" c="dimmed">
Last modified: {formatTimestamp(history.lastModified)}
</Text>
</Group>
)}
</Paper>
);
};
export default FileOperationHistory;
@@ -4,7 +4,7 @@ import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPages: string[];
selectedPages: number[];
onUpdatePagesFromCSV: () => void;
}
@@ -9,21 +9,21 @@ interface DragDropItem {
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
selectedItems: string[];
selectedItems: number[];
selectionMode: boolean;
isAnimating: boolean;
onDragStart: (itemId: string) => void;
onDragStart: (pageNumber: number) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (itemId: string) => void;
onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, targetId: string | 'end') => void;
onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void;
onEndZoneDragEnter: () => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
draggedItem: string | null;
dropTarget: string | null;
multiItemDrag: {itemIds: string[], count: number} | null;
draggedItem: number | null;
dropTarget: number | null;
multiItemDrag: {pageNumbers: number[], count: number} | null;
dragPosition: {x: number, y: number} | null;
}
@@ -77,7 +77,13 @@ const DragDropGrid = <T extends DragDropItem>({
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
paddingBottom: '100px'
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',
}}
>
{items.map((item, index) => (
@@ -1,561 +0,0 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import styles from './PageEditor.module.css';
import FileThumbnail from './FileThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel';
import DragDropGrid from './DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
size: number;
file: File;
splitBefore?: boolean;
}
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void;
activeFiles?: File[];
setActiveFiles?: (files: File[]) => void;
preSelectedFiles?: { file: File; url: string }[];
onClearPreSelection?: () => void;
}
const FileEditor = ({
onOpenPageEditor,
onMergeFiles,
activeFiles = [],
setActiveFiles,
preSelectedFiles = [],
onClearPreSelection
}: FileEditorProps) => {
const { t } = useTranslation();
const [files, setFiles] = useState<FileItem[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>('');
const [selectionMode, setSelectionMode] = useState(false);
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 fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// 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);
return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown').replace(/\.pdf$/i, ''),
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile,
};
}, []);
// Convert activeFiles to FileItem format
useEffect(() => {
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
setLoading(true);
try {
const convertedFiles = await Promise.all(
activeFiles.map(async (file) => {
const thumbnail = await generateThumbnailForFile(file);
return {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''),
pageCount: Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: file.size,
file,
};
})
);
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting active files:', err);
} finally {
setLoading(false);
}
} else {
setFiles([]);
}
};
convertActiveFiles();
}, [activeFiles]);
// Only load shared files when explicitly passed (not on mount)
useEffect(() => {
const loadSharedFiles = async () => {
// Only load if we have pre-selected files (coming from FileManager)
if (preSelectedFiles.length > 0) {
setLoading(true);
try {
const convertedFiles = await Promise.all(
preSelectedFiles.map(convertToFileItem)
);
if (setActiveFiles) {
const updatedActiveFiles = convertedFiles.map(fileItem => fileItem.file);
setActiveFiles(updatedActiveFiles);
}
} catch (err) {
console.error('Error converting pre-selected files:', err);
} finally {
setLoading(false);
}
}
};
loadSharedFiles();
}, [preSelectedFiles, convertToFileItem]);
// Handle pre-selected files
useEffect(() => {
if (preSelectedFiles.length > 0) {
const preSelectedIds = preSelectedFiles.map(f => f.id || f.name);
setSelectedFiles(preSelectedIds);
onClearPreSelection?.();
}
}, [preSelectedFiles, onClearPreSelection]);
// Process uploaded files
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setLoading(true);
setError(null);
try {
const newFiles: FileItem[] = [];
for (const file of uploadedFiles) {
if (file.type !== 'application/pdf') {
setError('Please upload only PDF files');
continue;
}
// Generate thumbnail and get page count
const thumbnail = await generateThumbnailForFile(file);
const fileItem: FileItem = {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''),
pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count
thumbnail,
size: file.size,
file,
};
newFiles.push(fileItem);
// Store in IndexedDB
await fileStorage.storeFile(file, thumbnail);
}
if (setActiveFiles) {
setActiveFiles(prev => [...prev, ...newFiles.map(f => f.file)]);
}
setStatus(`Added ${newFiles.length} files`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
console.error('File processing error:', err);
} finally {
setLoading(false);
}
}, [setActiveFiles]);
const selectAll = useCallback(() => {
setSelectedFiles(files.map(f => f.id));
}, [files]);
const deselectAll = useCallback(() => setSelectedFiles([]), []);
const toggleFile = useCallback((fileId: string) => {
setSelectedFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
}, []);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setSelectedFiles([]);
setCsvInput('');
}
return newMode;
});
}, []);
const parseCSVInput = useCallback((csv: string) => {
const fileIds: string[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end && i <= files.length; i++) {
if (i > 0) {
const file = files[i - 1];
if (file) fileIds.push(file.id);
}
}
} else {
const fileIndex = parseInt(range);
if (fileIndex > 0 && fileIndex <= files.length) {
const file = files[fileIndex - 1];
if (file) fileIds.push(file.id);
}
}
});
return fileIds;
}, [files]);
const updateFilesFromCSV = useCallback(() => {
const fileIds = parseCSVInput(csvInput);
setSelectedFiles(fileIds);
}, [csvInput, parseCSVInput]);
// Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId);
if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) {
setMultiFileDrag({
fileIds: selectedFiles,
count: selectedFiles.length
});
} else {
setMultiFileDrag(null);
}
}, [selectionMode, selectedFiles]);
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');
return;
}
setDropTarget(null);
}, [draggedFile, multiFileDrag]);
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 && selectedFiles.includes(draggedFile)
? selectedFiles
: [draggedFile];
if (setActiveFiles) {
// 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);
// Update activeFiles with the reordered File objects
setActiveFiles(newFiles.map(f => f.file));
return newFiles;
});
}
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
handleDragEnd();
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setActiveFiles]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
setDropTarget('end');
}
}, [draggedFile]);
// File operations
const handleDeleteFile = useCallback((fileId: string) => {
if (setActiveFiles) {
// Remove from local files and sync with activeFiles
setFiles(prev => {
const newFiles = prev.filter(f => f.id !== fileId);
setActiveFiles(newFiles.map(f => f.file));
return newFiles;
});
}
setSelectedFiles(prev => prev.filter(id => id !== fileId));
}, [setActiveFiles]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, onOpenPageEditor]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
if (startIndex === -1) return;
const filesToMerge = files.slice(startIndex).map(f => f.file);
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [files, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return;
setLoading(true);
try {
const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem)
);
setFiles(prev => [...prev, ...convertedFiles]);
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 {
setLoading(false);
}
}, [convertToFileItem]);
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading} />
<Box p="md" pt="xl">
<Group mb="md">
<Button
onClick={toggleSelectionMode}
variant={selectionMode ? "filled" : "outline"}
color={selectionMode ? "blue" : "gray"}
styles={{
root: {
transition: 'all 0.2s ease',
...(selectionMode && {
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
})
}
}}
>
{selectionMode ? "Exit Selection" : "Select Files"}
</Button>
{selectionMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{/* Load from storage and upload buttons */}
<Button
variant="outline"
color="blue"
onClick={() => setShowFilePickerModal(true)}
>
Load from Storage
</Button>
<Dropzone
onDrop={handleFileUpload}
accept={["application/pdf"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}
>
<Button variant="outline" color="green">
Upload Files
</Button>
</Dropzone>
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedFiles}
onUpdatePagesFromCSV={updateFilesFromCSV}
/>
)}
<DragDropGrid
items={files}
selectedItems={selectedFiles}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile}
dropTarget={dropTarget}
multiItemDrag={multiFileDrag}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
<FileThumbnail
file={file}
index={index}
totalFiles={files.length}
selectedFiles={selectedFiles}
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}
onMergeFromHere={handleMergeFromHere}
onSplitFile={handleSplitFile}
onSetStatus={setStatus}
/>
)}
renderSplitMarker={(file, index) => (
<div
style={{
width: '2px',
height: '24rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
allowMultiple={true}
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
)}
</Box>
);
};
export default FileEditor;
@@ -1,11 +1,11 @@
import React from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import React, { useState } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import MergeIcon from '@mui/icons-material/Merge';
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
import HistoryIcon from '@mui/icons-material/History';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import styles from './PageEditor.module.css';
import FileOperationHistory from '../history/FileOperationHistory';
interface FileItem {
id: string;
@@ -35,9 +35,8 @@ interface FileThumbnailProps {
onToggleFile: (fileId: string) => void;
onDeleteFile: (fileId: string) => void;
onViewFile: (fileId: string) => void;
onMergeFromHere: (fileId: string) => void;
onSplitFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
toolMode?: boolean;
}
const FileThumbnail = ({
@@ -59,10 +58,11 @@ const FileThumbnail = ({
onToggleFile,
onDeleteFile,
onViewFile,
onMergeFromHere,
onSplitFile,
onSetStatus,
toolMode = false,
}: FileThumbnailProps) => {
const [showHistory, setShowHistory] = useState(false);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -238,63 +238,53 @@ const FileThumbnail = ({
whiteSpace: 'nowrap'
}}
>
<Tooltip label="View File">
{!toolMode && (
<>
<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();
onViewFile(file.id);
onSetStatus(`Opened ${file.name}`);
setShowHistory(true);
onSetStatus(`Viewing history for ${file.name}`);
}}
>
<VisibilityIcon style={{ fontSize: 20 }} />
<HistoryIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Merge from here">
<Tooltip label="Close File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onMergeFromHere(file.id);
onSetStatus(`Starting merge from ${file.name}`);
}}
>
<MergeIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Split File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onSplitFile(file.id);
onSetStatus(`Opening ${file.name} in page editor`);
}}
>
<SplitscreenIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete File">
<ActionIcon
size="md"
variant="subtle"
c="red"
c="orange"
onClick={(e) => {
e.stopPropagation();
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
onSetStatus(`Closed ${file.name}`);
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
<CloseIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</div>
@@ -320,6 +310,21 @@ const FileThumbnail = ({
{formatFileSize(file.size)}
</Text>
</div>
{/* History Modal */}
<Modal
opened={showHistory}
onClose={() => setShowHistory(false)}
title={`Operation History - ${file.name}`}
size="lg"
scrollAreaComponent="div"
>
<FileOperationHistory
fileId={file.name}
showOnlyApplied={true}
maxHeight={500}
/>
</Modal>
</div>
);
};
@@ -1,10 +1,14 @@
/* Page container hover effects */
/* Page container hover effects - optimized for smooth scrolling */
.pageContainer {
transition: transform 0.2s ease-in-out;
/* Enable hardware acceleration for smoother scrolling */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.pageContainer:hover {
transform: scale(1.02);
transform: scale(1.02) translateZ(0);
}
.pageContainer:hover .pageNumber {
File diff suppressed because it is too large Load Diff
@@ -56,7 +56,7 @@ const PageEditorControls = ({
return (
<div
style={{
position: 'fixed',
position: 'absolute',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
@@ -7,42 +7,55 @@ 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 { PDFPage } from '../../../types/pageEditor';
import { PDFPage, PDFDocument } from '../../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../../commands/pageCommands';
import { Command } from '../../../hooks/useUndoRedo';
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;
index: number;
totalPages: number;
selectedPages: string[];
originalFile?: File; // For lazy thumbnail generation
selectedPages: number[];
selectionMode: boolean;
draggedPage: string | null;
dropTarget: string | null;
movingPage: string | null;
draggedPage: number | null;
dropTarget: number | null;
movingPage: number | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (pageId: string) => void;
onDragStart: (pageNumber: number) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageId: string) => void;
onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, pageId: string) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: (pageId: string, targetIndex: number) => void;
onExecuteCommand: (command: any) => 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: (pageId: string | null) => void;
RotatePagesCommand: any;
DeletePagesCommand: any;
ToggleSplitCommand: any;
pdfDocument: any;
setPdfDocument: any;
onSetMovingPage: (pageNumber: number | null) => void;
RotatePagesCommand: typeof RotatePagesCommand;
DeletePagesCommand: typeof DeletePagesCommand;
ToggleSplitCommand: typeof ToggleSplitCommand;
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
}
const PageThumbnail = ({
const PageThumbnail = React.memo(({
page,
index,
totalPages,
originalFile,
selectedPages,
selectionMode,
draggedPage,
@@ -67,6 +80,44 @@ const PageThumbnail = ({
pdfDocument,
setPdfDocument,
}: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
// Update thumbnail URL when page prop changes
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]);
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
useEffect(() => {
if (thumbnailUrl) {
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
return; // Skip if we already have a thumbnail
}
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
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})`);
if (pageNumber === page.pageNumber && pageId === page.id) {
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
setThumbnailUrl(thumbnail);
}
};
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
return () => {
console.log(`📸 PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`);
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
};
}, [page.pageNumber, page.id, thumbnailUrl]);
// Register this component with pageRefs for animations
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
if (element) {
@@ -79,7 +130,7 @@ const PageThumbnail = ({
return (
<div
ref={pageElementRef}
data-page-id={page.id}
data-page-number={page.pageNumber}
className={`
${styles.pageContainer}
!rounded-lg
@@ -96,12 +147,12 @@ const PageThumbnail = ({
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
${movingPage === page.id ? 'page-moving' : ''}
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
return 'translateX(20px)';
}
return 'translateX(0)';
@@ -109,12 +160,12 @@ const PageThumbnail = ({
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable
onDragStart={() => onDragStart(page.id)}
onDragStart={() => onDragStart(page.pageNumber)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(page.id)}
onDragEnter={() => onDragEnter(page.pageNumber)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.id)}
onDrop={(e) => onDrop(e, page.pageNumber)}
>
{selectionMode && (
<div
@@ -123,26 +174,31 @@ const PageThumbnail = ({
position: 'absolute',
top: 8,
right: 8,
zIndex: 4,
backgroundColor: 'white',
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '2px',
padding: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto'
pointerEvents: 'auto',
cursor: 'pointer'
}}
onMouseDown={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
console.log('📸 Checkbox clicked for page', page.pageNumber);
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
>
<Checkbox
checked={selectedPages.includes(page.id)}
onChange={(event) => {
event.stopPropagation();
onTogglePage(page.id);
checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false}
onChange={() => {
// onChange is handled by the parent div click
}}
onClick={(e) => e.stopPropagation()}
size="sm"
/>
</div>
@@ -162,18 +218,30 @@ const PageThumbnail = ({
justifyContent: 'center'
}}
>
<img
src={page.thumbnail}
alt={`Page ${page.pageNumber}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
}}
/>
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
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>
<Text size="xs" c="dimmed" mt={4}>Page {page.pageNumber}</Text>
</div>
)}
</div>
<Text
@@ -224,8 +292,8 @@ const PageThumbnail = ({
onClick={(e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.id);
onAnimateReorder(page.id, index - 1);
onSetMovingPage(page.pageNumber);
onAnimateReorder(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
@@ -244,8 +312,8 @@ const PageThumbnail = ({
onClick={(e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.id);
onAnimateReorder(page.id, index + 1);
onSetMovingPage(page.pageNumber);
onAnimateReorder(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
@@ -353,6 +421,20 @@ const PageThumbnail = ({
</div>
</div>
);
};
}, (prevProps, nextProps) => {
// 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
prevProps.selectionMode === nextProps.selectionMode &&
prevProps.draggedPage === nextProps.draggedPage &&
prevProps.dropTarget === nextProps.dropTarget &&
prevProps.movingPage === nextProps.movingPage &&
prevProps.isAnimating === nextProps.isAnimating
);
});
export default PageThumbnail;
+168
View File
@@ -0,0 +1,168 @@
import React, { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core";
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "../fileManagement/FileCard";
import { FileWithUrl } from "../../types/file";
interface FileGridProps {
files: FileWithUrl[];
onRemove?: (index: number) => void;
onDoubleClick?: (file: FileWithUrl) => void;
onView?: (file: FileWithUrl) => void;
onEdit?: (file: FileWithUrl) => void;
onSelect?: (fileId: string) => void;
selectedFiles?: string[];
showSearch?: boolean;
showSort?: boolean;
maxDisplay?: number; // If set, shows only this many files with "Show All" option
onShowAll?: () => void;
showingAll?: boolean;
onDeleteAll?: () => void;
}
type SortOption = 'date' | 'name' | 'size';
const FileGrid = ({
files,
onRemove,
onDoubleClick,
onView,
onEdit,
onSelect,
selectedFiles = [],
showSearch = false,
showSort = false,
maxDisplay,
onShowAll,
showingAll = false,
onDeleteAll
}: FileGridProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<SortOption>('date');
// Filter files based on search term
const filteredFiles = files.filter(file =>
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);
case 'name':
return a.name.localeCompare(b.name);
case 'size':
return (b.size || 0) - (a.size || 0);
default:
return 0;
}
});
// Apply max display limit if specified
const displayFiles = maxDisplay && !showingAll
? sortedFiles.slice(0, maxDisplay)
: sortedFiles;
const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay;
return (
<Box >
{/* Search and Sort Controls */}
{(showSearch || showSort || onDeleteAll) && (
<Group mb="md" justify="space-between" wrap="wrap" gap="sm">
<Group gap="sm">
{showSearch && (
<TextInput
placeholder={t("fileManager.searchFiles", "Search files...")}
leftSection={<SearchIcon size={16} />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
/>
)}
{showSort && (
<Select
data={[
{ value: 'date', label: t("fileManager.sortByDate", "Sort by Date") },
{ value: 'name', label: t("fileManager.sortByName", "Sort by Name") },
{ value: 'size', label: t("fileManager.sortBySize", "Sort by Size") }
]}
value={sortBy}
onChange={(value) => setSortBy(value as SortOption)}
leftSection={<SortIcon size={16} />}
style={{ minWidth: 150 }}
/>
)}
</Group>
{onDeleteAll && (
<Button
color="red"
size="sm"
onClick={onDeleteAll}
>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)}
</Group>
)}
{/* File Grid */}
<Flex
direction="row"
wrap="wrap"
gap="md"
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);
return (
<FileCard
key={fileId + idx}
file={file}
onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined}
onView={onView ? () => onView(file) : undefined}
onEdit={onEdit ? () => onEdit(file) : undefined}
isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect ? () => onSelect(fileId) : undefined}
/>
);
})}
</Flex>
{/* Show All Button */}
{hasMoreFiles && onShowAll && (
<Group justify="center" mt="md">
<Button
variant="light"
onClick={onShowAll}
>
{t("fileManager.showAll", "Show All")} ({sortedFiles.length} files)
</Button>
</Group>
)}
{/* Empty State */}
{displayFiles.length === 0 && (
<Box style={{ textAlign: 'center', padding: '2rem' }}>
<Text c="dimmed">
{searchTerm
? t("fileManager.noFilesFound", "No files found matching your search")
: t("fileManager.noFiles", "No files available")
}
</Text>
</Box>
)}
</Box>
);
};
export default FileGrid;
@@ -1,9 +1,13 @@
import React, { useState, useCallback, useRef } from 'react';
import { Stack, Button, Text, Center } from '@mantine/core';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useTranslation } from 'react-i18next';
import FilePickerModal from './FilePickerModal';
import { fileStorage } from '../../services/fileStorage';
import { FileWithUrl } from '../../types/file';
import FileGrid from './FileGrid';
import MultiSelectControls from './MultiSelectControls';
import { useFileManager } from '../../hooks/useFileManager';
interface FileUploadSelectorProps {
// Appearance
@@ -20,6 +24,10 @@ interface FileUploadSelectorProps {
// Loading state
loading?: boolean;
disabled?: boolean;
// Recent files
showRecentFiles?: boolean;
maxRecentFiles?: number;
}
const FileUploadSelector = ({
@@ -29,50 +37,94 @@ const FileUploadSelector = ({
sharedFiles = [],
onFileSelect,
onFilesSelect,
accept = ["application/pdf"],
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
loading = false,
disabled = false,
showRecentFiles = true,
maxRecentFiles = 8,
}: FileUploadSelectorProps) => {
const { t } = useTranslation();
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = useCallback((uploadedFiles: File[]) => {
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => {
if (uploadedFiles.length === 0) return;
if (showRecentFiles) {
try {
for (const file of uploadedFiles) {
await storeFile(file);
}
refreshRecentFiles();
} catch (error) {
console.error('Failed to save files to recent:', error);
}
}
if (onFilesSelect) {
onFilesSelect(uploadedFiles);
} else if (onFileSelect) {
onFileSelect(uploadedFiles[0]);
}
}, [onFileSelect, onFilesSelect]);
}, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]);
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files && files.length > 0) {
const fileArray = Array.from(files);
console.log('File input change:', fileArray.length, 'files');
handleFileUpload(fileArray);
handleNewFileUpload(fileArray);
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [handleFileUpload]);
}, [handleNewFileUpload]);
const openFileDialog = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleStorageSelection = useCallback((selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
if (onFilesSelect) {
onFilesSelect(selectedFiles);
} else if (onFileSelect) {
onFileSelect(selectedFiles[0]);
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
try {
const fileObj = await convertToFile(file);
if (onFilesSelect) {
onFilesSelect([fileObj]);
} else if (onFileSelect) {
onFileSelect(fileObj);
}
} catch (error) {
console.error('Failed to load file from recent:', error);
}
}, [onFileSelect, onFilesSelect]);
}, [onFileSelect, onFilesSelect, convertToFile]);
const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles);
const handleSelectedRecentFiles = useCallback(async () => {
if (onFilesSelect) {
await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect);
}
}, [recentFiles, onFilesSelect, selectionHandlers]);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
const file = recentFiles[index];
setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name)));
}, [handleRemoveFile, recentFiles]);
useEffect(() => {
if (showRecentFiles) {
refreshRecentFiles();
}
}, [showRecentFiles, refreshRecentFiles]);
// Get default title and subtitle from translations if not provided
const displayTitle = title || t("fileUpload.selectFiles", "Select files");
@@ -80,7 +132,7 @@ const FileUploadSelector = ({
return (
<>
<Stack align="center" gap="xl">
<Stack align="center" gap="sm">
{/* Title and description */}
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64 }} />
@@ -94,27 +146,14 @@ const FileUploadSelector = ({
{/* Action buttons */}
<Stack align="center" gap="md" w="100%">
<Button
variant="filled"
size="lg"
onClick={() => setShowFilePickerModal(true)}
disabled={disabled || sharedFiles.length === 0}
loading={loading}
>
{loading ? "Loading..." : `Load from Storage (${sharedFiles.length} files available)`}
</Button>
<Text size="md" c="dimmed">
{t("fileUpload.or", "or")}
</Text>
{showDropzone ? (
<Dropzone
onDrop={handleFileUpload}
onDrop={handleNewFileUpload}
accept={accept}
multiple={true}
disabled={disabled || loading}
style={{ width: '100%', minHeight: 120 }}
style={{ width: '100%', height: "5rem" }}
activateOnClick={true}
>
<Center>
@@ -123,7 +162,9 @@ const FileUploadSelector = ({
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
</Text>
<Text size="sm" c="dimmed">
{accept.includes('application/pdf')
{accept.includes('application/pdf') && accept.includes('application/zip')
? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files")
: accept.includes('application/pdf')
? t("fileUpload.pdfFilesOnly", "PDF files only")
: t("fileUpload.supportedFileTypes", "Supported file types")
}
@@ -142,7 +183,7 @@ const FileUploadSelector = ({
>
{t("fileUpload.uploadFiles", "Upload Files")}
</Button>
{/* Manual file input as backup */}
<input
ref={fileInputRef}
@@ -155,15 +196,46 @@ const FileUploadSelector = ({
</Stack>
)}
</Stack>
</Stack>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={sharedFiles}
onSelectFiles={handleStorageSelection}
/>
{/* Recent Files Section */}
{showRecentFiles && recentFiles.length > 0 && (
<Box w="100%" >
<Divider my="md" />
<Text size="lg" fw={500} mb="md">
{t("fileUpload.recentFiles", "Recent Files")}
</Text>
<MultiSelectControls
selectedCount={selectedFiles.length}
onClearSelection={selectionHandlers.clearSelection}
onAddToUpload={handleSelectedRecentFiles}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
<FileGrid
files={recentFiles}
onDoubleClick={handleRecentFileSelection}
onSelect={selectionHandlers.toggleSelection}
onRemove={handleRemoveFileByIndex}
selectedFiles={selectedFiles}
showSearch={true}
showSort={true}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
</Box>
)}
</Stack>
</>
);
};
@@ -0,0 +1,88 @@
import React from "react";
import { Box, Group, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface MultiSelectControlsProps {
selectedCount: number;
onClearSelection: () => void;
onOpenInFileEditor?: () => void;
onOpenInPageEditor?: () => void;
onAddToUpload?: () => void;
onDeleteAll?: () => void;
}
const MultiSelectControls = ({
selectedCount,
onClearSelection,
onOpenInFileEditor,
onOpenInPageEditor,
onAddToUpload,
onDeleteAll
}: MultiSelectControlsProps) => {
const { t } = useTranslation();
if (selectedCount === 0) return null;
return (
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between">
<Text size="sm">
{selectedCount} {t("fileManager.filesSelected", "files selected")}
</Text>
<Group>
<Button
size="xs"
variant="light"
onClick={onClearSelection}
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
{onAddToUpload && (
<Button
size="xs"
color="green"
onClick={onAddToUpload}
>
{t("fileManager.addToUpload", "Add to Upload")}
</Button>
)}
{onOpenInFileEditor && (
<Button
size="xs"
color="orange"
onClick={onOpenInFileEditor}
disabled={selectedCount === 0}
>
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
)}
{onOpenInPageEditor && (
<Button
size="xs"
color="blue"
onClick={onOpenInPageEditor}
disabled={selectedCount === 0}
>
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
)}
{onDeleteAll && (
<Button
size="xs"
color="red"
onClick={onDeleteAll}
>
{t("fileManager.deleteAll", "Delete All")}
</Button>
)}
</Group>
</Group>
</Box>
);
};
export default MultiSelectControls;
@@ -0,0 +1,106 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
}
const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const {
showNavigationWarning,
hasUnsavedChanges,
confirmNavigation,
cancelNavigation,
setHasUnsavedChanges
} = useFileContext();
const handleKeepWorking = () => {
cancelNavigation();
};
const handleDiscardChanges = () => {
setHasUnsavedChanges(false);
confirmNavigation();
};
const handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
if (!hasUnsavedChanges) {
return null;
}
return (
<Modal
opened={showNavigationWarning}
onClose={handleKeepWorking}
title="Unsaved Changes"
centered
closeOnClickOutside={false}
closeOnEscape={false}
>
<Stack gap="md">
<Text>
You have unsaved changes to your PDF. What would you like to do?
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="gray"
onClick={handleKeepWorking}
>
Keep Working
</Button>
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
Discard Changes
</Button>
{onApplyAndContinue && (
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
>
Apply & Continue
</Button>
)}
{onExportAndContinue && (
<Button
color="green"
onClick={handleExportAndContinue}
>
Export & Continue
</Button>
)}
</Group>
</Stack>
</Modal>
);
};
export default NavigationWarningModal;
@@ -0,0 +1,104 @@
import React from 'react';
import { Box, Group, Stack } from '@mantine/core';
interface SkeletonLoaderProps {
type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer';
count?: number;
animated?: boolean;
}
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
type,
count = 8,
animated = true
}) => {
const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
const renderPageGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
w="100%"
h={240}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
}}
/>
))}
</div>
);
const renderFileGridSkeleton = () => (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{Array.from({ length: count }).map((_, i) => (
<Box
key={i}
w="100%"
h={280}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle,
animationDelay: animated ? `${i * 0.1}s` : undefined
}}
/>
))}
</div>
);
const renderControlsSkeleton = () => (
<Group mb="md">
<Box w={150} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={120} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={100} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
</Group>
);
const renderViewerSkeleton = () => (
<Stack gap="md" h="100%">
{/* Toolbar skeleton */}
<Group>
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={80} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
</Group>
{/* Main content skeleton */}
<Box
flex={1}
bg="gray.1"
style={{
borderRadius: '8px',
...animationStyle
}}
/>
</Stack>
);
switch (type) {
case 'pageGrid':
return renderPageGridSkeleton();
case 'fileGrid':
return renderFileGridSkeleton();
case 'controls':
return renderControlsSkeleton();
case 'viewer':
return renderViewerSkeleton();
default:
return null;
}
};
export default SkeletonLoader;
+61 -28
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Button, SegmentedControl } from "@mantine/core";
import React, { useState, useCallback } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
import rainbowStyles from '../../styles/rainbow.module.css';
@@ -8,15 +8,19 @@ import LightModeIcon from '@mui/icons-material/LightMode';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
const VIEW_OPTIONS = [
// This will be created inside the component to access switchingTo
const createViewOptions = (switchingTo: string | null) => [
{
label: (
<Group gap={5}>
<VisibilityIcon fontSize="small" />
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
</Group>
),
value: "viewer",
@@ -24,7 +28,11 @@ const VIEW_OPTIONS = [
{
label: (
<Group gap={4}>
<EditNoteIcon fontSize="small" />
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
</Group>
),
value: "pageEditor",
@@ -32,15 +40,11 @@ const VIEW_OPTIONS = [
{
label: (
<Group gap={4}>
<InsertDriveFileIcon fontSize="small" />
</Group>
),
value: "fileManager",
},
{
label: (
<Group gap={4}>
<FolderIcon fontSize="small" />
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
</Group>
),
value: "fileEditor",
@@ -50,13 +54,34 @@ const VIEW_OPTIONS = [
interface TopControlsProps {
currentView: string;
setCurrentView: (view: string) => void;
selectedToolKey?: string | null;
}
const TopControls = ({
currentView,
setCurrentView,
selectedToolKey,
}: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
// Show immediate feedback
setSwitchingTo(view);
// Defer the heavy view change to next frame so spinner can render
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(view);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
}, [setCurrentView]);
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
@@ -66,7 +91,9 @@ const TopControls = ({
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
}`}>
<Button
onClick={toggleTheme}
variant="subtle"
@@ -87,18 +114,24 @@ const TopControls = ({
</Button>
<LanguageSelector />
</div>
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={VIEW_OPTIONS}
value={currentView}
onChange={setCurrentView}
color="blue"
radius="xl"
size="md"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
/>
</div>
{!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={createViewOptions(switchingTo)}
value={currentView}
onChange={handleViewChange}
color="blue"
radius="xl"
size="md"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
}}
/>
</div>
)}
</div>
);
};
+1 -1
View File
@@ -12,7 +12,7 @@ type ToolRegistry = {
};
interface ToolPickerProps {
selectedToolKey: string;
selectedToolKey: string | null;
onSelect: (id: string) => void;
toolRegistry: ToolRegistry;
}
+15 -20
View File
@@ -1,29 +1,30 @@
import React from "react";
import { FileWithUrl } from "../../types/file";
import { useToolManagement } from "../../hooks/useToolManagement";
interface ToolRendererProps {
selectedToolKey: string;
selectedTool: any;
pdfFile: any;
files: FileWithUrl[];
downloadUrl: string | null;
setDownloadUrl: (url: string | null) => void;
toolParams: any;
updateParams: (params: any) => void;
toolSelectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const ToolRenderer = ({
selectedToolKey,
selectedTool,
pdfFile,
files,
downloadUrl,
setDownloadUrl,
files,
toolParams,
updateParams,
toolSelectedFiles = [],
onPreviewFile,
}: ToolRendererProps) => {
// Get the tool from registry
const { toolRegistry } = useToolManagement();
const selectedTool = toolRegistry[selectedToolKey];
if (!selectedTool || !selectedTool.component) {
return <div>Tool not found</div>;
return <div>Tool not found: {selectedToolKey}</div>;
}
const ToolComponent = selectedTool.component;
@@ -33,19 +34,15 @@ const ToolRenderer = ({
case "split":
return (
<ToolComponent
file={pdfFile}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
selectedFiles={toolSelectedFiles}
onPreviewFile={onPreviewFile}
/>
);
case "compress":
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
setLoading={(loading: boolean) => {}} // TODO: Add loading state
setLoading={(loading: boolean) => {}}
params={toolParams}
updateParams={updateParams}
/>
@@ -54,7 +51,6 @@ const ToolRenderer = ({
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
/>
@@ -63,7 +59,6 @@ const ToolRenderer = ({
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
/>
@@ -71,4 +66,4 @@ const ToolRenderer = ({
}
};
export default ToolRenderer;
export default ToolRenderer;
@@ -0,0 +1,35 @@
import { Notification } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export interface ErrorNotificationProps {
error: string | null;
onClose: () => void;
title?: string;
color?: string;
mb?: string;
}
const ErrorNotification = ({
error,
onClose,
title,
color = 'red',
mb = 'md'
}: ErrorNotificationProps) => {
const { t } = useTranslation();
if (!error) return null;
return (
<Notification
color={color}
title={title || t("error._value", "Error")}
onClose={onClose}
mb={mb}
>
{error}
</Notification>
);
}
export default ErrorNotification;
@@ -0,0 +1,40 @@
import React from 'react';
import { Text } from '@mantine/core';
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
isCompleted?: boolean;
placeholder?: string;
showFileName?: boolean;
}
const FileStatusIndicator = ({
selectedFiles = [],
isCompleted = false,
placeholder = "Select a PDF file in the main view to get started",
showFileName = true
}: FileStatusIndicatorProps) => {
if (selectedFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
{placeholder}
</Text>
);
}
if (isCompleted) {
return (
<Text size="sm" c="green">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
}
return (
<Text size="sm" c="blue">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
}
export default FileStatusIndicator;
@@ -0,0 +1,51 @@
import React from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export interface OperationButtonProps {
onClick?: () => void;
isLoading?: boolean;
disabled?: boolean;
loadingText?: string;
submitText?: string;
variant?: 'filled' | 'outline' | 'subtle';
color?: string;
fullWidth?: boolean;
mt?: string;
type?: 'button' | 'submit' | 'reset';
}
const OperationButton = ({
onClick,
isLoading = false,
disabled = false,
loadingText,
submitText,
variant = 'filled',
color = 'blue',
fullWidth = true,
mt = 'md',
type = 'button'
}: OperationButtonProps) => {
const { t } = useTranslation();
return (
<Button
type={type}
onClick={onClick}
fullWidth={fullWidth}
mt={mt}
loading={isLoading}
disabled={disabled}
variant={variant}
color={color}
>
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
</Button>
);
}
export default OperationButton;
@@ -0,0 +1,112 @@
import { Grid, Paper, Box, Image, Text, Loader, Stack, Center } from '@mantine/core';
export interface ResultFile {
file: File;
thumbnail?: string;
}
export interface ResultsPreviewProps {
files: ResultFile[];
isGeneratingThumbnails?: boolean;
onFileClick?: (file: File) => void;
title?: string;
emptyMessage?: string;
loadingMessage?: string;
}
const ResultsPreview = ({
files,
isGeneratingThumbnails = false,
onFileClick,
title,
emptyMessage = "No files to preview",
loadingMessage = "Generating previews..."
}: ResultsPreviewProps) => {
const formatSize = (size: number) => {
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
};
if (files.length === 0 && !isGeneratingThumbnails) {
return (
<Text size="sm" c="dimmed">
{emptyMessage}
</Text>
);
}
return (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
{title && (
<Text fw={500} size="md" mb="sm">
{title} ({files.length} files)
</Text>
)}
{isGeneratingThumbnails ? (
<Center p="lg">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
) : (
<Grid>
{files.map((result, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
onClick={() => onFileClick?.(result.file)}
style={{
textAlign: 'center',
height: '10rem',
width:'5rem',
display: 'flex',
flexDirection: 'column',
cursor: onFileClick ? 'pointer' : 'default',
transition: 'all 0.2s ease'
}}
>
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.thumbnail ? (
<Image
src={result.thumbnail}
alt={`Preview of ${result.file.name}`}
style={{
maxWidth: '100%',
maxHeight: '9rem',
objectFit: 'contain'
}}
/>
) : (
<Text size="xs" c="dimmed">No preview</Text>
)}
</Box>
<Text
size="xs"
c="dimmed"
mt="xs"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={result.file.name}
>
{result.file.name}
</Text>
<Text size="xs" c="dimmed">
{formatSize(result.file.size)}
</Text>
</Paper>
</Grid.Col>
))}
</Grid>
)}
</Box>
);
}
export default ResultsPreview;
@@ -0,0 +1,120 @@
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Paper, Text, Stack, Box } from '@mantine/core';
interface ToolStepContextType {
visibleStepCount: number;
getStepNumber: () => number;
}
const ToolStepContext = createContext<ToolStepContextType | null>(null);
export interface ToolStepProps {
title: string;
isVisible?: boolean;
isCollapsed?: boolean;
isCompleted?: boolean;
onCollapsedClick?: () => void;
children?: React.ReactNode;
completedMessage?: string;
helpText?: string;
showNumber?: boolean;
}
const ToolStep = ({
title,
isVisible = true,
isCollapsed = false,
isCompleted = false,
onCollapsedClick,
children,
completedMessage,
helpText,
showNumber
}: ToolStepProps) => {
if (!isVisible) return null;
// Auto-detect if we should show numbers based on sibling count
const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber;
const parent = useContext(ToolStepContext);
return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber]);
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
return (
<Paper
p="md"
withBorder
style={{
cursor: isCollapsed && onCollapsedClick ? 'pointer' : 'default',
opacity: isCollapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
onClick={isCollapsed && onCollapsedClick ? onCollapsedClick : undefined}
>
<Text fw={500} size="lg" mb="sm">
{shouldShowNumber ? `${stepNumber}. ` : ''}{title}
</Text>
{isCollapsed ? (
<Box>
{isCompleted && completedMessage && (
<Text size="sm" c="green">
{completedMessage}
{onCollapsedClick && (
<Text span c="dimmed" size="xs" ml="sm">
(click to change)
</Text>
)}
</Text>
)}
</Box>
) : (
<Stack gap="md">
{helpText && (
<Text size="sm" c="dimmed">
{helpText}
</Text>
)}
{children}
</Stack>
)}
</Paper>
);
}
export interface ToolStepContainerProps {
children: React.ReactNode;
}
export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
const stepCounterRef = useRef(0);
// Count visible ToolStep children
const visibleStepCount = useMemo(() => {
let count = 0;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = child.props.isVisible !== false;
if (isVisible) count++;
}
});
return count;
}, [children]);
const contextValue = useMemo(() => ({
visibleStepCount,
getStepNumber: () => ++stepCounterRef.current
}), [visibleStepCount]);
stepCounterRef.current = 0;
return (
<ToolStepContext.Provider value={contextValue}>
{children}
</ToolStepContext.Provider>
);
}
export default ToolStep;
@@ -0,0 +1,148 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
export interface SplitParameters {
pages: string;
hDiv: string;
vDiv: string;
merge: boolean;
splitType: SplitType | '';
splitValue: string;
bookmarkLevel: string;
includeMetadata: boolean;
allowDuplicates: boolean;
}
export interface SplitSettingsProps {
mode: SplitMode | '';
onModeChange: (mode: SplitMode | '') => void;
parameters: SplitParameters;
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
disabled?: boolean;
}
const SplitSettings = ({
mode,
onModeChange,
parameters,
onParameterChange,
disabled = false
}: SplitSettingsProps) => {
const { t } = useTranslation();
const renderByPagesForm = () => (
<TextInput
label={t("split.splitPages", "Pages")}
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
value={parameters.pages}
onChange={(e) => onParameterChange('pages', e.target.value)}
disabled={disabled}
/>
);
const renderBySectionsForm = () => (
<Stack gap="sm">
<TextInput
label={t("split-by-sections.horizontal.label", "Horizontal Divisions")}
type="number"
min="0"
max="300"
value={parameters.hDiv}
onChange={(e) => onParameterChange('hDiv', e.target.value)}
placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
disabled={disabled}
/>
<TextInput
label={t("split-by-sections.vertical.label", "Vertical Divisions")}
type="number"
min="0"
max="300"
value={parameters.vDiv}
onChange={(e) => onParameterChange('vDiv', e.target.value)}
placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
disabled={disabled}
/>
<Checkbox
label={t("split-by-sections.merge", "Merge sections into one PDF")}
checked={parameters.merge}
onChange={(e) => onParameterChange('merge', e.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
const renderBySizeOrCountForm = () => (
<Stack gap="sm">
<Select
label={t("split-by-size-or-count.type.label", "Split Type")}
value={parameters.splitType}
onChange={(v) => v && onParameterChange('splitType', v)}
disabled={disabled}
data={[
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
{ value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
{ value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") },
]}
/>
<TextInput
label={t("split-by-size-or-count.value.label", "Split Value")}
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
value={parameters.splitValue}
onChange={(e) => onParameterChange('splitValue', e.target.value)}
disabled={disabled}
/>
</Stack>
);
const renderByChaptersForm = () => (
<Stack gap="sm">
<TextInput
label={t("splitByChapters.bookmarkLevel", "Bookmark Level")}
type="number"
value={parameters.bookmarkLevel}
onChange={(e) => onParameterChange('bookmarkLevel', e.target.value)}
disabled={disabled}
/>
<Checkbox
label={t("splitByChapters.includeMetadata", "Include Metadata")}
checked={parameters.includeMetadata}
onChange={(e) => onParameterChange('includeMetadata', e.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t("splitByChapters.allowDuplicates", "Allow Duplicate Bookmarks")}
checked={parameters.allowDuplicates}
onChange={(e) => onParameterChange('allowDuplicates', e.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
return (
<Stack gap="md">
{/* Mode Selector */}
<Select
label="Choose split method"
placeholder="Select how to split the PDF"
value={mode}
onChange={(v) => v && onModeChange(v)}
disabled={disabled}
data={[
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
{ value: SPLIT_MODES.BY_SECTIONS, label: t("split-by-sections.title", "Split by Grid Sections") },
{ value: SPLIT_MODES.BY_SIZE_OR_COUNT, label: t("split-by-size-or-count.title", "Split by Size or Count") },
{ value: SPLIT_MODES.BY_CHAPTERS, label: t("splitByChapters.title", "Split by Chapters") },
]}
/>
{/* Parameter Form */}
{mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
{mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
{mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
{mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
</Stack>
);
}
export default SplitSettings;
+232 -117
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core";
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 ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
@@ -9,8 +9,12 @@ import LastPageIcon from "@mui/icons-material/LastPage";
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
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 { useFileWithUrl } from "../../hooks/useFileWithUrl";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
@@ -29,7 +33,7 @@ const LazyPageImage = ({
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
}: LazyPageImageProps) => {
const [isVisible, setIsVisible] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(pageImages[pageIndex]);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
@@ -54,6 +58,13 @@ const LazyPageImage = ({
return () => observer.disconnect();
}, [imageUrl]);
// Update local state when pageImages changes (from preloading)
useEffect(() => {
if (pageImages[pageIndex]) {
setImageUrl(pageImages[pageIndex]);
}
}, [pageImages, pageIndex]);
useEffect(() => {
if (isVisible && !imageUrl) {
renderPage(pageIndex).then((url) => {
@@ -123,20 +134,40 @@ const LazyPageImage = ({
};
export interface ViewerProps {
pdfFile: { file: File; url: string } | null; // First file in the array
setPdfFile: (file: { file: File; url: string } | null) => void;
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File; // For preview mode - bypasses context
}
const Viewer = ({
pdfFile,
setPdfFile,
sidebarsVisible,
setSidebarsVisible,
onClose,
previewFile,
}: ViewerProps) => {
const { t } = useTranslation();
const theme = useMantineTheme();
// Get current file from FileContext
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
const currentFile = getCurrentFile();
const processedFile = getCurrentProcessedFile();
// Convert File to FileWithUrl format for viewer
const pdfFile = useFileWithUrl(currentFile);
// Tab management for multiple files
const [activeTab, setActiveTab] = useState<string>("0");
// Reset PDF state when switching tabs
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
setNumPages(0);
setPageImages([]);
setCurrentPage(null);
setLoading(true);
};
const [numPages, setNumPages] = useState<number>(0);
const [pageImages, setPageImages] = useState<string[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -144,16 +175,50 @@ const Viewer = ({
const [dualPage, setDualPage] = useState(false);
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
// Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl(activeFiles[0]);
const file1WithUrl = useFileWithUrl(activeFiles[1]);
const file2WithUrl = useFileWithUrl(activeFiles[2]);
const file3WithUrl = useFileWithUrl(activeFiles[3]);
const file4WithUrl = useFileWithUrl(activeFiles[4]);
const filesWithUrls = React.useMemo(() => {
return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl]
.slice(0, activeFiles.length)
.filter(Boolean);
}, [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl, activeFiles.length]);
// Use preview file if available, otherwise use active tab file
const effectiveFile = React.useMemo(() => {
if (previewFile) {
// Validate the preview file
if (!(previewFile instanceof File)) {
return null;
}
if (previewFile.size === 0) {
return null;
}
return { file: previewFile, url: null };
} else {
// Use the file from the active tab
const tabIndex = parseInt(activeTab);
return filesWithUrls[tabIndex] || null;
}
}, [previewFile, filesWithUrls, activeTab]);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const userInitiatedRef = useRef(false);
const suppressScrollRef = useRef(false);
const pdfDocRef = useRef<any>(null);
const renderingPagesRef = useRef<Set<number>>(new Set());
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
const preloadingRef = useRef<boolean>(false);
// Function to render a specific page on-demand
const renderPage = async (pageIndex: number): Promise<string | null> => {
if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
return null;
}
@@ -194,70 +259,78 @@ const Viewer = ({
return null;
};
// Listen for hash changes and update currentPage
useEffect(() => {
function handleHashChange() {
if (window.location.hash.startsWith("#page=")) {
const page = parseInt(window.location.hash.replace("#page=", ""), 10);
if (!isNaN(page) && page >= 1 && page <= numPages) {
setCurrentPage(page);
}
// Progressive preloading function
const startProgressivePreload = async () => {
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
preloadingRef.current = true;
// Start with first few pages for immediate viewing
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
// Render priority pages first
for (const pageIndex of priorityPages) {
if (pageIndex < numPages && !pageImages[pageIndex]) {
await renderPage(pageIndex);
// Small delay to allow UI to update
await new Promise(resolve => setTimeout(resolve, 50));
}
userInitiatedRef.current = false;
}
window.addEventListener("hashchange", handleHashChange);
handleHashChange(); // Run on mount
return () => window.removeEventListener("hashchange", handleHashChange);
}, [numPages]);
// Scroll to the current page when it changes
useEffect(() => {
if (currentPage && pageRefs.current[currentPage - 1]) {
suppressScrollRef.current = true;
const el = pageRefs.current[currentPage - 1];
el?.scrollIntoView({ behavior: "smooth", block: "center" });
// Try to use scrollend if supported
const viewport = scrollAreaRef.current;
let timeout: NodeJS.Timeout | null = null;
let scrollEndHandler: (() => void) | null = null;
if (viewport && "onscrollend" in viewport) {
scrollEndHandler = () => {
suppressScrollRef.current = false;
viewport.removeEventListener("scrollend", scrollEndHandler!);
};
viewport.addEventListener("scrollend", scrollEndHandler);
} else {
// Fallback for non-Chromium browsers
timeout = setTimeout(() => {
suppressScrollRef.current = false;
}, 1000);
// Then render remaining pages in background
for (let pageIndex = 5; pageIndex < numPages; pageIndex++) {
if (!pageImages[pageIndex]) {
await renderPage(pageIndex);
// Longer delay for background loading to not block UI
await new Promise(resolve => setTimeout(resolve, 100));
}
return () => {
if (viewport && scrollEndHandler) {
viewport.removeEventListener("scrollend", scrollEndHandler);
}
if (timeout) clearTimeout(timeout);
};
}
}, [currentPage, pageImages]);
preloadingRef.current = false;
};
// Detect visible page on scroll and update hash
const handleScroll = () => {
if (suppressScrollRef.current) return;
// Initialize current page when PDF loads
useEffect(() => {
if (numPages > 0 && !currentPage) {
setCurrentPage(1);
}
}, [numPages, currentPage]);
// Function to scroll to a specific page
const scrollToPage = (pageNumber: number) => {
const el = pageRefs.current[pageNumber - 1];
const scrollArea = scrollAreaRef.current;
if (el && scrollArea) {
const scrollAreaRect = scrollArea.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const currentScrollTop = scrollArea.scrollTop;
// Position page near top of viewport with some padding
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
scrollArea.scrollTo({
top: targetScrollTop,
behavior: "smooth"
});
}
};
// Throttled scroll handler to prevent jerky updates
const handleScrollThrottled = useCallback(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea || !pageRefs.current.length) return;
const areaRect = scrollArea.getBoundingClientRect();
const viewportCenter = areaRect.top + areaRect.height / 2;
let closestIdx = 0;
let minDist = Infinity;
pageRefs.current.forEach((img, idx) => {
if (img) {
const imgRect = img.getBoundingClientRect();
const dist = Math.abs(imgRect.top - areaRect.top);
const imgCenter = imgRect.top + imgRect.height / 2;
const dist = Math.abs(imgCenter - viewportCenter);
if (dist < minDist) {
minDist = dist;
closestIdx = idx;
@@ -265,30 +338,41 @@ const Viewer = ({
}
});
// Update page number display only if changed
if (currentPage !== closestIdx + 1) {
setCurrentPage(closestIdx + 1);
if (window.location.hash !== `#page=${closestIdx + 1}`) {
window.location.hash = `#page=${closestIdx + 1}`;
}
}
};
}, [currentPage]);
// Throttle scroll events to reduce jerkiness
const handleScroll = useCallback(() => {
if (window.requestAnimationFrame) {
window.requestAnimationFrame(handleScrollThrottled);
} else {
handleScrollThrottled();
}
}, [handleScrollThrottled]);
useEffect(() => {
let cancelled = false;
async function loadPdfInfo() {
if (!pdfFile || !pdfFile.url) {
if (!effectiveFile) {
setNumPages(0);
setPageImages([]);
return;
}
setLoading(true);
try {
let pdfUrl = pdfFile.url;
let pdfData;
// For preview files, use ArrayBuffer directly to avoid blob URL issues
if (previewFile && effectiveFile.file === previewFile) {
const arrayBuffer = await previewFile.arrayBuffer();
pdfData = { data: arrayBuffer };
}
// Handle special IndexedDB URLs for large files
if (pdfFile.url.startsWith('indexeddb:')) {
const fileId = pdfFile.url.replace('indexeddb:', '');
console.log('Loading large file from IndexedDB:', fileId);
else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', '');
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId);
@@ -298,21 +382,23 @@ const Viewer = ({
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;
// Use ArrayBuffer directly instead of creating blob URL
const pdf = await getDocument({ data: arrayBuffer }).promise;
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
} else {
pdfData = { data: arrayBuffer };
} else if (effectiveFile.url) {
// Standard blob URL or regular URL
const pdf = await getDocument(pdfUrl).promise;
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
pdfData = effectiveFile.url;
} else {
throw new Error('No valid PDF source available');
}
const pdf = await getDocument(pdfData).promise;
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
setPageImages(new Array(pdf.numPages).fill(null));
// Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100);
}
} catch (error) {
console.error('Failed to load PDF:', error);
if (!cancelled) {
setPageImages([]);
setNumPages(0);
@@ -323,10 +409,12 @@ const Viewer = ({
loadPdfInfo();
return () => {
cancelled = true;
// Stop any ongoing preloading
preloadingRef.current = false;
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
}, [pdfFile]);
}, [effectiveFile, previewFile]);
useEffect(() => {
const viewport = scrollAreaRef.current;
@@ -339,39 +427,62 @@ const Viewer = ({
}, [pageImages]);
return (
<>
{!pdfFile ? (
<Box style={{ position: 'relative', height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Close Button - Only show in preview mode */}
{onClose && previewFile && (
<ActionIcon
variant="filled"
color="gray"
size="lg"
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
borderRadius: '50%',
}}
onClick={onClose}
>
<CloseIcon />
</ActionIcon>
)}
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Stack align="center">
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
<Button
component="label"
variant="outline"
color="blue"
>
{t("viewer.choosePdf", "Choose PDF")}
<input
type="file"
accept="application/pdf"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type === "application/pdf") {
const fileUrl = URL.createObjectURL(file);
setPdfFile({ file, url: fileUrl });
}
}}
/>
</Button>
</Stack>
</Center>
) : loading ? (
<Center style={{ flex: 1 }}>
<Loader size="lg" />
<Text c="red">Error: No file provided to viewer</Text>
</Center>
) : (
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box
style={{
borderBottom: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
position: 'relative',
zIndex: 100,
marginTop: '60px' // Push tabs below TopControls
}}
>
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
<Tabs.List>
{activeFiles.map((file, index) => (
<Tabs.Tab key={index} value={index.toString()}>
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
</Box>
)}
{loading ? (
<div style={{ flex: 1, padding: '1rem' }}>
<SkeletonLoader type="viewer" />
</div>
) : (
<ScrollArea
style={{ flex: 1, height: "100vh", position: "relative"}}
style={{ flex: 1, position: "relative"}}
viewportRef={scrollAreaRef}
>
<Stack gap="xl" align="center" >
@@ -456,7 +567,7 @@ const Viewer = ({
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=1`;
scrollToPage(1);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
@@ -470,7 +581,8 @@ const Viewer = ({
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${Math.max(1, (currentPage || 1) - 1)}`;
const prevPage = Math.max(1, (currentPage || 1) - 1);
scrollToPage(prevPage);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
@@ -482,7 +594,7 @@ const Viewer = ({
onChange={value => {
const page = Number(value);
if (!isNaN(page) && page >= 1 && page <= numPages) {
window.location.hash = `#page=${page}`;
scrollToPage(page);
}
}}
min={1}
@@ -502,7 +614,8 @@ const Viewer = ({
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${Math.min(numPages, (currentPage || 1) + 1)}`;
const nextPage = Math.min(numPages, (currentPage || 1) + 1);
scrollToPage(nextPage);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
@@ -516,7 +629,7 @@ const Viewer = ({
px={8}
radius="xl"
onClick={() => {
window.location.hash = `#page=${numPages}`;
scrollToPage(numPages);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
@@ -558,9 +671,11 @@ const Viewer = ({
</Paper>
</div>
</ScrollArea>
)}
</>
)}
</>
</Box>
);
};