mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
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:
co-authored by
Anthony Stirling
parent
584e2ecee7
commit
922bbc9076
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ type ToolRegistry = {
|
||||
};
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string;
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
toolRegistry: ToolRegistry;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user