import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, Center, Box, LoadingOverlay, Stack } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; import { useNavigationActions } from '../../contexts/NavigationContext'; import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; import FileEditorThumbnail from './FileEditorThumbnail'; import AddFileCard from './AddFileCard'; import FilePickerModal from '../shared/FilePickerModal'; import { FileId, StirlingFile } from '../../types/fileContext'; import { alert } from '../toast'; import { downloadBlob } from '../../utils/downloadUtils'; interface FileEditorProps { onOpenPageEditor?: () => void; onMergeFiles?: (files: StirlingFile[]) => void; toolMode?: boolean; supportedExtensions?: string[]; } const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => { // Utility function to check if a file extension is supported const isFileSupported = useCallback((fileName: string): boolean => { const extension = detectFileExtension(fileName); return extension ? supportedExtensions.includes(extension) : false; }, [supportedExtensions]); // Use optimized FileContext hooks const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); const { actions } = useFileActions(); // Extract needed values from state (memoized to prevent infinite loops) const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; // Get navigation actions const { actions: navActions } = useNavigationActions(); // Get file selection context const { setSelectedFiles } = useFileSelection(); const [_status, _setStatus] = useState(null); const [_error, _setError] = useState(null); // Toast helpers const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => { alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); }, []); const showError = useCallback((message: string) => { alert({ alertType: 'error', title: 'Error', body: message, expandable: true }); }, []); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode useEffect(() => { if (toolMode) { setSelectionMode(true); } }, [toolMode]); const [showFilePickerModal, setShowFilePickerModal] = useState(false); // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; // Create refs for frequently changing values to stabilize callbacks const contextSelectedIdsRef = useRef([]); contextSelectedIdsRef.current = contextSelectedIds; // Use activeStirlingFileStubs directly - no conversion needed const localSelectedIds = contextSelectedIds; // Process uploaded files using context // ZIP extraction is now handled automatically in FileContext based on user preferences const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { _setError(null); try { if (uploadedFiles.length > 0) { // FileContext will automatically handle ZIP extraction based on user preferences // - Respects autoUnzip setting // - Respects autoUnzipFileLimit // - HTML ZIPs stay intact // - Non-ZIP files pass through unchanged await addFiles(uploadedFiles, { selectFiles: true }); showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; showError(errorMessage); console.error('File processing error:', err); } }, [addFiles, showStatus, showError]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId); if (!targetRecord) return; const contextFileId = fileId; // No need to create a new ID const isSelected = currentSelectedIds.includes(contextFileId); let newSelection: FileId[]; if (isSelected) { // Remove file from selection newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection // In tool mode, typically allow multiple files unless specified otherwise const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools if (maxAllowed === 1) { newSelection = [contextFileId]; } else { // Check if we've hit the selection limit if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); return; } newSelection = [...currentSelectedIds, contextFileId]; } } // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); // File reordering handler for drag and drop const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { const currentIds = activeStirlingFileStubs.map(r => r.id); // Find indices const sourceIndex = currentIds.findIndex(id => id === sourceFileId); const targetIndex = currentIds.findIndex(id => id === targetFileId); if (sourceIndex === -1 || targetIndex === -1) { console.warn('Could not find source or target file for reordering'); return; } // Handle multi-file selection reordering const filesToMove = selectedFileIds.length > 1 ? selectedFileIds.filter(id => currentIds.includes(id)) : [sourceFileId]; // Create new order const newOrder = [...currentIds]; // Remove files to move from their current positions (in reverse order to maintain indices) const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id)) .sort((a, b) => b - a); // Sort descending sourceIndices.forEach(index => { newOrder.splice(index, 1); }); // Calculate insertion index after removals let insertIndex = newOrder.findIndex(id => id === targetFileId); if (insertIndex !== -1) { // Determine if moving forward or backward const isMovingForward = sourceIndex < targetIndex; if (isMovingForward) { // Moving forward: insert after target insertIndex += 1; } else { // Moving backward: insert before target (insertIndex already correct) } } else { // Target was moved, insert at end insertIndex = newOrder.length; } // Insert files at the calculated position newOrder.splice(insertIndex, 0, ...filesToMove); // Update file order reorderFiles(newOrder); // Update status const moveCount = filesToMove.length; showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); }, [activeStirlingFileStubs, reorderFiles, _setStatus]); // File operations using context const handleCloseFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { // Remove file from context but keep in storage (close, don't delete) const contextFileId = record.id; removeFiles([contextFileId], false); // Remove from context selections const currentSelected = selectedFileIds.filter(id => id !== contextFileId); setSelectedFiles(currentSelected); } }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]); const handleDownloadFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { downloadBlob(file, file.name); } }, [activeStirlingFileStubs, selectors, _setStatus]); const handleUnzipFile = useCallback(async (fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { try { // Extract and store files using shared service method const result = await zipFileService.extractAndStoreFilesWithHistory(file, record); if (result.success && result.extractedStubs.length > 0) { // Add extracted file stubs to FileContext await actions.addStirlingFileStubs(result.extractedStubs); // Remove the original ZIP file removeFiles([fileId], false); alert({ alertType: 'success', title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`, expandable: false, durationMs: 3500 }); } else { alert({ alertType: 'error', title: `Failed to extract files from ${file.name}`, body: result.errors.join('\n'), expandable: true, durationMs: 3500 }); } } catch (error) { console.error('Failed to unzip file:', error); alert({ alertType: 'error', title: `Error unzipping ${file.name}`, expandable: false, durationMs: 3500 }); } } }, [activeStirlingFileStubs, selectors, actions, removeFiles]); const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); if (record) { // Set the file as selected in context and switch to viewer for preview setSelectedFiles([fileId]); navActions.setWorkbench('viewer'); } }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; try { // Use FileContext to handle loading stored files // The files are already in FileContext, just need to add them to active files showStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); showError('Failed to load some files from storage'); } }, []); return ( {activeStirlingFileStubs.length === 0 ? (
📁 No files loaded Upload PDF files, ZIP archives, or load from storage to get started
) : (
{/* Add File Card - only show when files exist */} {activeStirlingFileStubs.length > 0 && ( )} {activeStirlingFileStubs.map((record, index) => { return ( ); })}
)}
{/* File Picker Modal */} setShowFilePickerModal(false)} storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent onSelectFiles={handleLoadFromStorage} />
); }; export default FileEditor;