diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index d633c5163..efb1ee69e 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -707,6 +707,16 @@ "tags": "workflow,sequence,automation", "title": "Automate", "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." + }, + "mobile": { + "brandAlt": "Stirling PDF logo", + "viewSwitcher": "Switch workspace view", + "tools": "Tools", + "workspace": "Workspace", + "swipeHint": "Swipe left or right to switch views", + "toolsSlide": "Tool selection panel", + "workbenchSlide": "Workspace panel", + "openFiles": "Open files" } }, "landing": { diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index 8186b449e..16738127d 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import { Box } from '@mantine/core'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; @@ -6,7 +6,7 @@ import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileState } from '../../contexts/FileContext'; import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext'; import { useViewer } from '../../contexts/ViewerContext'; -import { PageEditorProvider } from '../../contexts/PageEditorContext'; +import { PageEditorProvider, usePageEditor } from '../../contexts/PageEditorContext'; import './Workbench.css'; import TopControls from '../shared/TopControls'; @@ -18,16 +18,45 @@ import LandingPage from '../shared/LandingPage'; import Footer from '../shared/Footer'; import DismissAllErrorsButton from '../shared/DismissAllErrorsButton'; +// Syncs PageEditorContext with FileContext on activeFiles change +function PageEditorSync({ activeFiles }: { activeFiles: Array<{ fileId: any; name: string; versionNumber?: number }> }) { + const { syncWithFileContext } = usePageEditor(); + + // Create stable signature to prevent unnecessary syncs + const fileSignature = useMemo( + () => activeFiles.map(f => `${f.fileId}-${f.name}-${f.versionNumber || 0}`).join('|'), + [activeFiles] + ); + + useEffect(() => { + // Filter for PDFs only - Page Editor doesn't support images + const pdfFiles = activeFiles.filter(f => + f.name.toLowerCase().endsWith('.pdf') + ); + + const fileData = pdfFiles.map(f => ({ + fileId: f.fileId, + name: f.name, + versionNumber: f.versionNumber, + })); + syncWithFileContext(fileData); + }, [fileSignature, syncWithFileContext, activeFiles]); + + return null; +} + // No props needed - component uses contexts directly export default function Workbench() { const { isRainbowMode } = useRainbowThemeContext(); // Use context-based hooks to eliminate all prop drilling - const { selectors } = useFileState(); + const { state, selectors } = useFileState(); const { workbench: currentView } = useNavigationState(); const { actions: navActions } = useNavigationActions(); const setCurrentView = navActions.setWorkbench; - const activeFiles = selectors.getFiles(); + + // Create stable reference for activeFiles based on file IDs + const activeFiles = useMemo(() => selectors.getFiles(), [state.files.ids.join(',')]); const { previewFile, pageEditorFunctions, @@ -149,6 +178,7 @@ export default function Workbench() { return ( + { - syncWithFileContext(state.files.ids); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileIdsString]); // Only re-run when the actual IDs change - - // Get active file IDs from selected files (maintains order from FileContext) + // Get active file IDs from SELECTED files only const activeFileIds = useMemo(() => { - return state.files.ids.filter(id => selectedFileIds.has(id)); - // Using string representations to prevent infinite loops - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileIdsString, selectedIdsString]); + return pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId); + }, [pageEditorFiles]); // UI state const globalProcessing = state.ui.isProcessing; @@ -155,11 +143,19 @@ const PageEditor = ({ }; setEditedDocument(reorderedDocument); clearReorderedPages(); + // Clear the source after applying to prevent feedback loop + clearReorderSource(); } - }, [reorderedPages, displayDocument, clearReorderedPages]); + }, [reorderedPages, displayDocument, clearReorderedPages, clearReorderSource]); // Update file order when pages are manually reordered useEffect(() => { + // Skip if the last reorder came from file-level (prevent feedback loop) + if (lastReorderSource === 'file') { + clearReorderSource(); + return; + } + if (editedDocument?.pages && editedDocument.pages.length > 0 && activeFileIds.length > 1) { // Compute the file order based on page positions const fileFirstPagePositions = new Map(); @@ -177,7 +173,7 @@ const PageEditor = ({ .map(entry => entry[0]); // Check if the order has actually changed - const currentFileOrder = state.files.ids.filter(id => selectedFileIds.has(id)); + const currentFileOrder = pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId); const orderChanged = computedFileOrder.length !== currentFileOrder.length || computedFileOrder.some((id, index) => id !== currentFileOrder[index]); @@ -185,7 +181,7 @@ const PageEditor = ({ updateFileOrderFromPages(editedDocument.pages); } } - }, [editedDocument?.pages, activeFileIds.length, state.files.ids, selectedFileIds, updateFileOrderFromPages]); + }, [editedDocument?.pages, activeFileIds.length, state.files.ids, pageEditorFiles, updateFileOrderFromPages, lastReorderSource, clearReorderSource]); // Utility functions to convert between page IDs and page numbers const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => { @@ -729,14 +725,28 @@ const PageEditor = ({ // Display all pages - use edited or original document const displayedPages = displayDocument?.pages || []; - // Create a mapping of fileId to color index for page highlighting + // Track color assignments by insertion order (files keep their color) + const fileColorAssignments = useRef(new Map()); + + // Create a stable mapping of fileId to color index (preserves colors on reorder) const fileColorIndexMap = useMemo(() => { - const map = new Map(); - activeFileIds.forEach((fileId, index) => { - map.set(fileId, index); + // Assign colors to new files based on insertion order + activeFileIds.forEach(fileId => { + if (!fileColorAssignments.current.has(fileId)) { + fileColorAssignments.current.set(fileId, fileColorAssignments.current.size); + } }); - return map; - }, [activeFileIds]); + + // Clean up removed files + const activeSet = new Set(activeFileIds); + for (const fileId of fileColorAssignments.current.keys()) { + if (!activeSet.has(fileId)) { + fileColorAssignments.current.delete(fileId); + } + } + + return fileColorAssignments.current; + }, [activeFileIds.join(',')]); // Only recalculate when the set of files changes, not the order return ( diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 9388a498b..38848703e 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -68,6 +68,7 @@ const PageThumbnail: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); + const lastClickTimeRef = useRef(0); const dragElementRef = useRef(null); const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); @@ -263,7 +264,12 @@ const PageThumbnail: React.FC = ({ // If mouse moved less than 5 pixels, consider it a click (not a drag) if (distance < 5 && !isDragging) { - onTogglePage(page.id); + // Prevent rapid double-clicks from causing issues (debounce with 100ms threshold) + const now = Date.now(); + if (now - lastClickTimeRef.current > 100) { + lastClickTimeRef.current = now; + onTogglePage(page.id); + } } setIsMouseDown(false); @@ -275,7 +281,7 @@ const PageThumbnail: React.FC = ({ setMouseStartPos(null); }, []); - const fileColorBorder = getFileColorWithOpacity(fileColorIndex, 0.5); + const fileColorBorder = getFileColorWithOpacity(fileColorIndex, 0.3); return (
= ({ hover:shadow-md transition-all relative - bg-white hover:bg-gray-50 ${isDragging ? 'opacity-50 scale-95' : ''} ${movingPage === page.pageNumber ? 'page-moving' : ''} `} diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index c4a75a029..06552d9dc 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -16,18 +16,20 @@ export interface PageDocumentHook { */ export function usePageDocument(): PageDocumentHook { const { state, selectors } = useFileState(); - const { selectedFileIds } = usePageEditor(); + const { files: pageEditorFiles } = usePageEditor(); // Convert Set to array and filter to maintain file order from FileContext const allFileIds = state.files.ids; // Create stable string representations for useMemo dependencies const allFileIdsString = allFileIds.join(','); - const selectedIdsString = Array.from(selectedFileIds).sort().join(','); + const selectedFiles = pageEditorFiles.filter(f => f.isSelected); + const selectedIdsString = selectedFiles.map(f => f.fileId).sort().join(','); const activeFileIds = useMemo(() => { + const selectedFileIds = new Set(selectedFiles.map(f => f.fileId)); return allFileIds.filter(id => selectedFileIds.has(id)); - // Using string representations to prevent infinite loops from Set reference changes + // Using string representations to prevent infinite loops // eslint-disable-next-line react-hooks/exhaustive-deps }, [allFileIdsString, selectedIdsString]); diff --git a/frontend/src/components/shared/PageEditorFileDropdown.tsx b/frontend/src/components/shared/PageEditorFileDropdown.tsx index 6a8f59c90..b5444ee9e 100644 --- a/frontend/src/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/components/shared/PageEditorFileDropdown.tsx @@ -12,11 +12,12 @@ import FitText from './FitText'; import { getFileColorWithOpacity } from '../pageEditor/fileColors'; import { FileId } from '../../types/file'; +import { PageEditorFile } from '../../contexts/PageEditorContext'; interface FileMenuItemProps { - file: { fileId: FileId; name: string; versionNumber?: number }; + file: PageEditorFile; index: number; - isSelected: boolean; + colorIndex: number; isFirst: boolean; isLast: boolean; onToggleSelection: (fileId: FileId) => void; @@ -30,7 +31,7 @@ interface FileMenuItemProps { const FileMenuItem: React.FC = ({ file, index, - isSelected, + colorIndex, isFirst, isLast, onToggleSelection, @@ -101,8 +102,8 @@ const FileMenuItem: React.FC = ({ }, [file.fileId, index, onReorder]); const itemName = file?.name || 'Untitled'; - const fileColorBorder = getFileColorWithOpacity(index, 1); - const fileColorBorderHover = getFileColorWithOpacity(index, 1.0); + const fileColorBorder = getFileColorWithOpacity(colorIndex, 1); + const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0); return (
= ({ padding: '0.75rem 0.75rem', marginBottom: '0.5rem', cursor: isDragging ? 'grabbing' : 'grab', - backgroundColor: isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent', + backgroundColor: isDragOver ? 'rgba(59, 130, 246, 0.15)' : (file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'), borderLeft: `6px solid ${fileColorBorder}`, - borderTop: isDragOver ? '2px solid rgba(0, 0, 0, 0.5)' : 'none', - borderBottom: isDragOver ? '2px solid rgba(0, 0, 0, 0.5)' : 'none', + borderTop: isDragOver ? '3px solid rgb(59, 130, 246)' : 'none', + borderBottom: isDragOver ? '3px solid rgb(59, 130, 246)' : 'none', opacity: isDragging ? 0.5 : 1, - transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease, border-color 0.15s ease', + transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease, border 0.15s ease', userSelect: 'none', }} onMouseEnter={(e) => { @@ -131,7 +132,7 @@ const FileMenuItem: React.FC = ({ }} onMouseLeave={(e) => { if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'; (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder; } }} @@ -149,7 +150,7 @@ const FileMenuItem: React.FC = ({
onToggleSelection(file.fileId)} onClick={(e) => e.stopPropagation()} size="sm" @@ -169,7 +170,7 @@ const FileMenuItem: React.FC = ({ size="sm" variant="subtle" disabled={isFirst} - onClick={onMoveToTop} + onClick={(e) => onMoveToTop(e, index)} title="Move to top" > @@ -179,7 +180,7 @@ const FileMenuItem: React.FC = ({ size="sm" variant="subtle" disabled={isFirst} - onClick={onMoveUp} + onClick={(e) => onMoveUp(e, index)} title="Move up" > @@ -189,7 +190,7 @@ const FileMenuItem: React.FC = ({ size="sm" variant="subtle" disabled={isLast} - onClick={onMoveDown} + onClick={(e) => onMoveDown(e, index)} title="Move down" > @@ -199,7 +200,7 @@ const FileMenuItem: React.FC = ({ size="sm" variant="subtle" disabled={isLast} - onClick={onMoveToBottom} + onClick={(e) => onMoveToBottom(e, index)} title="Move to bottom" > @@ -212,22 +213,22 @@ const FileMenuItem: React.FC = ({ interface PageEditorFileDropdownProps { displayName: string; - allFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>; - selectedFileIds: Set; + files: PageEditorFile[]; onToggleSelection: (fileId: FileId) => void; onReorder: (fromIndex: number, toIndex: number) => void; switchingTo?: string | null; viewOptionStyle: React.CSSProperties; + fileColorMap: Map; } export const PageEditorFileDropdown: React.FC = ({ displayName, - allFiles, - selectedFileIds, + files, onToggleSelection, onReorder, switchingTo, viewOptionStyle, + fileColorMap, }) => { const handleMoveUp = (e: React.MouseEvent, index: number) => { e.stopPropagation(); @@ -238,7 +239,7 @@ export const PageEditorFileDropdown: React.FC = ({ const handleMoveDown = (e: React.MouseEvent, index: number) => { e.stopPropagation(); - if (index < allFiles.length - 1) { + if (index < files.length - 1) { onReorder(index, index + 1); } }; @@ -252,8 +253,8 @@ export const PageEditorFileDropdown: React.FC = ({ const handleMoveToBottom = (e: React.MouseEvent, index: number) => { e.stopPropagation(); - if (index < allFiles.length - 1) { - onReorder(index, allFiles.length - 1); + if (index < files.length - 1) { + onReorder(index, files.length - 1); } }; @@ -278,17 +279,17 @@ export const PageEditorFileDropdown: React.FC = ({ maxHeight: '80vh', overflowY: 'auto' }}> - {allFiles.map((file, index) => { - const isSelected = selectedFileIds.has(file.fileId); + {files.map((file, index) => { const isFirst = index === 0; - const isLast = index === allFiles.length - 1; + const isLast = index === files.length - 1; + const colorIndex = fileColorMap.get(file.fileId as string) ?? 0; return ( void, pageEditorState?: { - allFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>; - selectedFileIds: Set; + files: Array<{ fileId: FileId; name: string; versionNumber?: number; isSelected: boolean }>; selectedCount: number; totalCount: number; onToggleSelection: (fileId: FileId) => void; onReorder: (fromIndex: number, toIndex: number) => void; + fileColorMap: Map; } ) => { const currentFile = activeFiles[currentFileIndex]; @@ -82,12 +84,12 @@ const createViewOptions = ( label: showPageEditorDropdown ? ( ) : (
@@ -149,14 +151,50 @@ const TopControls = ({ // Get page editor state for dropdown const { - selectedFileIds, + files: pageEditorFiles = [], toggleFileSelection, reorderFiles: pageEditorReorderFiles, } = usePageEditor(); - // Convert Set to array for counting - const selectedCount = selectedFileIds.size; - const totalCount = activeFiles.length; + // Convert to counts + const selectedCount = pageEditorFiles?.filter(f => f.isSelected).length || 0; + const totalCount = pageEditorFiles?.length || 0; + + // Create stable file IDs string for dependency (only changes when file set changes) + const fileIdsString = (pageEditorFiles || []).map(f => f.fileId).sort().join(','); + + // Track color assignments by insertion order (files keep their color) + const fileColorAssignments = React.useRef(new Map()); + + // Create stable file color mapping (preserves colors on reorder) + const fileColorMap = useMemo(() => { + const map = new Map(); + if (!pageEditorFiles || pageEditorFiles.length === 0) return map; + + const allFileIds = (pageEditorFiles || []).map(f => f.fileId as string); + + // Assign colors to new files based on insertion order + allFileIds.forEach(fileId => { + if (!fileColorAssignments.current.has(fileId)) { + fileColorAssignments.current.set(fileId, fileColorAssignments.current.size); + } + }); + + // Clean up removed files + const activeSet = new Set(allFileIds); + for (const fileId of fileColorAssignments.current.keys()) { + if (!activeSet.has(fileId)) { + fileColorAssignments.current.delete(fileId); + } + } + + return fileColorAssignments.current; + }, [fileIdsString]); + + // Memoize the reorder handler - now much simpler! + const handleReorder = useCallback((fromIndex: number, toIndex: number) => { + pageEditorReorderFiles(fromIndex, toIndex); + }, [pageEditorReorderFiles]); const handleViewChange = useCallback((view: string) => { if (!isValidWorkbench(view)) { @@ -191,12 +229,12 @@ const TopControls = ({ currentFileIndex, onFileSelect, { - allFiles: activeFiles as Array<{ fileId: FileId; name: string; versionNumber?: number }>, - selectedFileIds, + files: pageEditorFiles, selectedCount, totalCount, onToggleSelection: toggleFileSelection, - onReorder: (fromIndex, toIndex) => pageEditorReorderFiles(fromIndex, toIndex, activeFiles.map(f => f.fileId as FileId)), + onReorder: handleReorder, + fileColorMap, } )} value={currentView} diff --git a/frontend/src/contexts/PageEditorContext.tsx b/frontend/src/contexts/PageEditorContext.tsx index fe3ee7e78..a73d94c38 100644 --- a/frontend/src/contexts/PageEditorContext.tsx +++ b/frontend/src/contexts/PageEditorContext.tsx @@ -4,6 +4,13 @@ import { useFileActions } from './FileContext'; import { PDFPage } from '../types/pageEditor'; import { MAX_PAGE_EDITOR_FILES } from '../components/pageEditor/fileColors'; +export interface PageEditorFile { + fileId: FileId; + name: string; + versionNumber?: number; + isSelected: boolean; +} + /** * Computes file order based on the position of each file's first page * @param pages - Current page order @@ -31,10 +38,10 @@ function computeFileOrderFromPages(pages: PDFPage[]): FileId[] { } /** - * Reorders pages based on file reordering while preserving manual page order within files - * @param currentPages - Current page order (may include manual reordering) - * @param fromIndex - Source file index - * @param toIndex - Target file index + * Reorders pages based on file reordering while preserving interlacing and manual page order + * @param currentPages - Current page order (may include manual reordering and interlacing) + * @param fromIndex - Source file index in the file order + * @param toIndex - Target file index in the file order * @param orderedFileIds - File IDs in their current order * @returns Reordered pages with updated page numbers */ @@ -44,43 +51,60 @@ function reorderPagesForFileMove( toIndex: number, orderedFileIds: FileId[] ): PDFPage[] { - // Group pages by originalFileId, preserving their current relative positions - const fileGroups = new Map(); + // Get the file ID being moved + const movedFileId = orderedFileIds[fromIndex]; + const targetFileId = orderedFileIds[toIndex]; + + // Extract pages belonging to the moved file (maintaining their relative order) + const movedFilePages: PDFPage[] = []; + const remainingPages: PDFPage[] = []; currentPages.forEach(page => { - const fileId = page.originalFileId; - if (!fileId) return; - - if (!fileGroups.has(fileId)) { - fileGroups.set(fileId, []); + if (page.originalFileId === movedFileId) { + movedFilePages.push(page); + } else { + remainingPages.push(page); } - fileGroups.get(fileId)!.push(page); }); - // Reorder the file IDs - const newFileOrder = [...orderedFileIds]; - const [movedFileId] = newFileOrder.splice(fromIndex, 1); - newFileOrder.splice(toIndex, 0, movedFileId); + // Find the insertion point based on the target file + let insertionIndex = 0; - // Rebuild pages in new file order, preserving page order within each file - const reorderedPages: PDFPage[] = []; + if (fromIndex < toIndex) { + // Moving down: insert AFTER the last page of target file + for (let i = remainingPages.length - 1; i >= 0; i--) { + if (remainingPages[i].originalFileId === targetFileId) { + insertionIndex = i + 1; + break; + } + } + } else { + // Moving up: insert BEFORE the first page of target file + for (let i = 0; i < remainingPages.length; i++) { + if (remainingPages[i].originalFileId === targetFileId) { + insertionIndex = i; + break; + } + } + } - newFileOrder.forEach(fileId => { - const filePages = fileGroups.get(fileId) || []; - reorderedPages.push(...filePages); - }); + // Insert moved pages at the calculated position + const reorderedPages = [ + ...remainingPages.slice(0, insertionIndex), + ...movedFilePages, + ...remainingPages.slice(insertionIndex) + ]; - // Renumber all pages sequentially - reorderedPages.forEach((page, index) => { - page.pageNumber = index + 1; - }); - - return reorderedPages; + // Renumber all pages sequentially (clone to avoid mutation) + return reorderedPages.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); } interface PageEditorContextValue { - // Set of selected file IDs (for quick lookup) - selectedFileIds: Set; + // Single array of files with selection state + files: PageEditorFile[]; // Current page order (updated by PageEditor, used for file reordering) currentPages: PDFPage[] | null; @@ -90,21 +114,28 @@ interface PageEditorContextValue { reorderedPages: PDFPage[] | null; clearReorderedPages: () => void; + // Set file selection + setFileSelection: (fileId: FileId, selected: boolean) => void; + // Toggle file selection toggleFileSelection: (fileId: FileId) => void; // Select/deselect all files - selectAll: (fileIds: FileId[]) => void; + selectAll: () => void; deselectAll: () => void; - // Reorder ALL files in FileContext (maintains selection state and page order) - reorderFiles: (fromIndex: number, toIndex: number, allFileIds: FileId[]) => void; + // Reorder files (simple array reordering) + reorderFiles: (fromIndex: number, toIndex: number) => void; // Update file order based on page positions (when pages are manually reordered) updateFileOrderFromPages: (pages: PDFPage[]) => void; + // Track mutation source to prevent feedback loops + lastReorderSource: 'file' | 'page' | null; + clearReorderSource: () => void; + // Sync with FileContext when files change - syncWithFileContext: (allFileIds: FileId[]) => void; + syncWithFileContext: (fileContextFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>) => void; } const PageEditorContext = createContext(undefined); @@ -115,10 +146,12 @@ interface PageEditorProviderProps { } export function PageEditorProvider({ children, initialFileIds = [] }: PageEditorProviderProps) { - // Use Set for O(1) selection lookup - const [selectedFileIds, setSelectedFileIds] = useState>(new Set(initialFileIds)); + // Single array of files with selection state + const [files, setFiles] = useState([]); const [currentPages, setCurrentPages] = useState(null); const [reorderedPages, setReorderedPages] = useState(null); + const [lastReorderSource, setLastReorderSource] = useState<'file' | 'page' | null>(null); + const lastReorderSourceAtRef = React.useRef(0); const { actions: fileActions } = useFileActions(); const updateCurrentPages = useCallback((pages: PDFPage[] | null) => { @@ -129,56 +162,129 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor setReorderedPages(null); }, []); - const toggleFileSelection = useCallback((fileId: FileId) => { - setSelectedFileIds(prev => { - const next = new Set(prev); - if (next.has(fileId)) { - next.delete(fileId); - } else { - // Check if adding this file would exceed the limit - if (next.size >= MAX_PAGE_EDITOR_FILES) { + const clearReorderSource = useCallback(() => { + setLastReorderSource(null); + }, []); + + const setFileSelection = useCallback((fileId: FileId, selected: boolean) => { + setFiles(prev => { + const selectedCount = prev.filter(f => f.isSelected).length; + + // Check if we're trying to select when at limit + if (selected && selectedCount >= MAX_PAGE_EDITOR_FILES) { + const alreadySelected = prev.find(f => f.fileId === fileId)?.isSelected; + if (!alreadySelected) { console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`); return prev; } - next.add(fileId); } - return next; + + return prev.map(f => + f.fileId === fileId ? { ...f, isSelected: selected } : f + ); }); }, []); - const selectAll = useCallback((fileIds: FileId[]) => { - // Enforce maximum file limit - if (fileIds.length > MAX_PAGE_EDITOR_FILES) { - console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`); - const limitedFiles = fileIds.slice(0, MAX_PAGE_EDITOR_FILES); - setSelectedFileIds(new Set(limitedFiles)); - } else { - setSelectedFileIds(new Set(fileIds)); - } + const toggleFileSelection = useCallback((fileId: FileId) => { + setFiles(prev => { + const file = prev.find(f => f.fileId === fileId); + if (!file) return prev; + + const selectedCount = prev.filter(f => f.isSelected).length; + + // If toggling on and at limit, don't allow + if (!file.isSelected && selectedCount >= MAX_PAGE_EDITOR_FILES) { + console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`); + return prev; + } + + return prev.map(f => + f.fileId === fileId ? { ...f, isSelected: !f.isSelected } : f + ); + }); + }, []); + + const selectAll = useCallback(() => { + setFiles(prev => { + if (prev.length > MAX_PAGE_EDITOR_FILES) { + console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`); + return prev.map((f, index) => ({ ...f, isSelected: index < MAX_PAGE_EDITOR_FILES })); + } + return prev.map(f => ({ ...f, isSelected: true })); + }); }, []); const deselectAll = useCallback(() => { - setSelectedFileIds(new Set()); + setFiles(prev => prev.map(f => ({ ...f, isSelected: false }))); }, []); - const reorderFiles = useCallback((fromIndex: number, toIndex: number, allFileIds: FileId[]) => { - // Reorder the entire file list in FileContext - const newOrder = [...allFileIds]; - const [movedFile] = newOrder.splice(fromIndex, 1); - newOrder.splice(toIndex, 0, movedFile); + const reorderFiles = useCallback((fromIndex: number, toIndex: number) => { + let newFileIds: FileId[] = []; + let reorderedPagesResult: PDFPage[] | null = null; - // Update global FileContext order - fileActions.reorderFiles(newOrder); + // Mark that this reorder came from file-level action + setLastReorderSource('file'); + lastReorderSourceAtRef.current = Date.now(); - // If current pages available, reorder them based on file move - if (currentPages && currentPages.length > 0) { - const reordered = reorderPagesForFileMove(currentPages, fromIndex, toIndex, allFileIds); - setReorderedPages(reordered); + setFiles(prev => { + // Simple array reordering + const newOrder = [...prev]; + const [movedFile] = newOrder.splice(fromIndex, 1); + newOrder.splice(toIndex, 0, movedFile); + + // Collect file IDs for later FileContext update + newFileIds = newOrder.map(f => f.fileId); + + // If current pages available, reorder them based on file move + if (currentPages && currentPages.length > 0 && fromIndex !== toIndex) { + // Get the current file order from pages (files that have pages loaded) + const currentFileOrder: FileId[] = []; + const filesSeen = new Set(); + currentPages.forEach(page => { + const fileId = page.originalFileId; + if (fileId && !filesSeen.has(fileId)) { + filesSeen.add(fileId); + currentFileOrder.push(fileId); + } + }); + + // Get the moved and target file IDs + const movedFileId = prev[fromIndex].fileId; + const targetFileId = prev[toIndex].fileId; + + // Find their positions in the current page order (not the full file list) + const pageOrderFromIndex = currentFileOrder.findIndex(id => id === movedFileId); + const pageOrderToIndex = currentFileOrder.findIndex(id => id === targetFileId); + + // Only reorder pages if both files have pages loaded + if (pageOrderFromIndex >= 0 && pageOrderToIndex >= 0) { + reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder); + } + } + + return newOrder; + }); + + // Update FileContext after state settles + if (newFileIds.length > 0) { + fileActions.reorderFiles(newFileIds); + } + + // Update reordered pages after state settles + if (reorderedPagesResult) { + setReorderedPages(reorderedPagesResult); } }, [fileActions, currentPages]); const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => { if (!pages || pages.length === 0) return; + // Suppress page-derived reorder if a recent explicit file reorder just occurred (prevents feedback loop) + if (lastReorderSource === 'file' && Date.now() - lastReorderSourceAtRef.current < 500) { + return; + } + + setLastReorderSource('page'); + lastReorderSourceAtRef.current = Date.now(); // Compute the new file order based on page positions const newFileOrder = computeFileOrderFromPages(pages); @@ -187,54 +293,65 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor // Update global FileContext order fileActions.reorderFiles(newFileOrder); } - }, [fileActions]); + }, [fileActions, lastReorderSource]); - const syncWithFileContext = useCallback((allFileIds: FileId[]) => { - setSelectedFileIds(prev => { - // Remove IDs that no longer exist in FileContext - const next = new Set(); - allFileIds.forEach(id => { - if (prev.has(id)) { - next.add(id); - } + const syncWithFileContext = useCallback((fileContextFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>) => { + setFiles(prev => { + // Create a map of existing files for quick lookup + const existingMap = new Map(prev.map(f => [f.fileId, f])); + + // Build new files array from FileContext, preserving selection state + const newFiles: PageEditorFile[] = fileContextFiles.map(file => { + const existing = existingMap.get(file.fileId); + return { + fileId: file.fileId, + name: file.name, + versionNumber: file.versionNumber, + isSelected: existing?.isSelected ?? false, // Preserve selection or default to false + }; }); // If no files selected, select all by default (up to MAX_PAGE_EDITOR_FILES) - if (next.size === 0 && allFileIds.length > 0) { - const filesToSelect = allFileIds.slice(0, MAX_PAGE_EDITOR_FILES); - if (allFileIds.length > MAX_PAGE_EDITOR_FILES) { + const selectedCount = newFiles.filter(f => f.isSelected).length; + if (selectedCount === 0 && newFiles.length > 0) { + const maxToSelect = Math.min(newFiles.length, MAX_PAGE_EDITOR_FILES); + if (newFiles.length > MAX_PAGE_EDITOR_FILES) { console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`); } - return new Set(filesToSelect); + return newFiles.map((f, index) => ({ + ...f, + isSelected: index < maxToSelect, + })); } // Enforce maximum file limit - if (next.size > MAX_PAGE_EDITOR_FILES) { + if (selectedCount > MAX_PAGE_EDITOR_FILES) { console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Limiting selection.`); - const limitedFiles = Array.from(next).slice(0, MAX_PAGE_EDITOR_FILES); - return new Set(limitedFiles); + let selectedSoFar = 0; + return newFiles.map(f => ({ + ...f, + isSelected: f.isSelected && selectedSoFar++ < MAX_PAGE_EDITOR_FILES, + })); } - // Only update if there's an actual change - if (next.size === prev.size && Array.from(next).every(id => prev.has(id))) { - return prev; // No change, return same reference - } - - return next; + return newFiles; }); }, []); const value: PageEditorContextValue = { - selectedFileIds, + files, currentPages, updateCurrentPages, reorderedPages, clearReorderedPages, + setFileSelection, toggleFileSelection, selectAll, deselectAll, reorderFiles, updateFileOrderFromPages, + lastReorderSource, + clearReorderSource, syncWithFileContext, };