diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index 16738127d..8a0c2d41d 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -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, usePageEditor } from '../../contexts/PageEditorContext'; +import { PageEditorProvider } from '../../contexts/PageEditorContext'; import './Workbench.css'; import TopControls from '../shared/TopControls'; @@ -18,33 +18,6 @@ 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(); @@ -79,9 +52,6 @@ export default function Workbench() { // Get active file index from ViewerContext const { activeFileIndex, setActiveFileIndex } = useViewer(); - // Get all file IDs for PageEditor initialization - const allFileIds = useMemo(() => activeFiles.map(f => f.fileId), [activeFiles]); - const handlePreviewClose = () => { setPreviewFile(null); const previousMode = sessionStorage.getItem('previousMode'); @@ -177,8 +147,7 @@ export default function Workbench() { }; return ( - - + ()); + + const pageEditorFiles = useMemo(() => { + const cache = fileObjectsRef.current; + const newFiles: any[] = []; + + state.files.ids.forEach(fileId => { + const stub = selectors.getStirlingFileStub(fileId); + const isSelected = state.ui.selectedFileIds.includes(fileId); + const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false; + + if (!isPdf) return; // Skip non-PDFs + + const cached = cache.get(fileId); + + // Check if data actually changed (compare by fileId, not position) + if (cached && + cached.fileId === fileId && + cached.name === (stub?.name || '') && + cached.versionNumber === stub?.versionNumber && + cached.isSelected === isSelected) { + // Reuse existing object reference + newFiles.push(cached); + } else { + // Create new object only if data changed + const newFile = { + fileId, + name: stub?.name || '', + versionNumber: stub?.versionNumber, + isSelected, + }; + cache.set(fileId, newFile); + newFiles.push(newFile); + } + }); + + // Clean up removed files from cache + const activeIds = new Set(newFiles.map(f => f.fileId)); + for (const cachedId of cache.keys()) { + if (!activeIds.has(cachedId)) { + cache.delete(cachedId); + } + } + + return newFiles; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileIdsKey, selectedIdsKey, filesSignature]); // Get active file IDs from SELECTED files only const activeFileIds = useMemo(() => { @@ -143,45 +199,15 @@ const PageEditor = ({ }; setEditedDocument(reorderedDocument); clearReorderedPages(); - // Clear the source after applying to prevent feedback loop - clearReorderSource(); } - }, [reorderedPages, displayDocument, clearReorderedPages, clearReorderSource]); + }, [reorderedPages, displayDocument, clearReorderedPages]); // 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(); - editedDocument.pages.forEach((page, index) => { - const fileId = page.originalFileId; - if (!fileId) return; - if (!fileFirstPagePositions.has(fileId)) { - fileFirstPagePositions.set(fileId, index); - } - }); - - // Sort files by their first page position - const computedFileOrder = Array.from(fileFirstPagePositions.entries()) - .sort((a, b) => a[1] - b[1]) - .map(entry => entry[0]); - - // Check if the order has actually changed - const currentFileOrder = pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId); - const orderChanged = computedFileOrder.length !== currentFileOrder.length || - computedFileOrder.some((id, index) => id !== currentFileOrder[index]); - - if (orderChanged && computedFileOrder.length > 0) { - updateFileOrderFromPages(editedDocument.pages); - } + updateFileOrderFromPages(editedDocument.pages); } - }, [editedDocument?.pages, activeFileIds.length, state.files.ids, pageEditorFiles, updateFileOrderFromPages, lastReorderSource, clearReorderSource]); + }, [editedDocument?.pages, activeFileIds.length, updateFileOrderFromPages]); // Utility functions to convert between page IDs and page numbers const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => { diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index 06552d9dc..6eb764361 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -1,6 +1,5 @@ import { useMemo } from 'react'; import { useFileState } from '../../../contexts/FileContext'; -import { usePageEditor } from '../../../contexts/PageEditorContext'; import { PDFDocument, PDFPage } from '../../../types/pageEditor'; import { FileId } from '../../../types/file'; @@ -16,28 +15,29 @@ export interface PageDocumentHook { */ export function usePageDocument(): PageDocumentHook { const { state, selectors } = useFileState(); - 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 selectedFiles = pageEditorFiles.filter(f => f.isSelected); - const selectedIdsString = selectedFiles.map(f => f.fileId).sort().join(','); + // Derive selected file IDs directly from FileContext (single source of truth) + // Filter to only include PDF files (PageEditor only supports PDFs) + // Use stable string keys to prevent infinite loops + const allFileIdsKey = allFileIds.join(','); + const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(','); + const filesSignature = selectors.getFilesSignature(); 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 + const selectedFileIds = new Set(state.ui.selectedFileIds); + return allFileIds.filter(id => { + if (!selectedFileIds.has(id)) return false; + const stub = selectors.getStirlingFileStub(id); + return stub?.name?.toLowerCase().endsWith('.pdf') ?? false; + }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allFileIdsString, selectedIdsString]); + }, [allFileIdsKey, selectedIdsKey, filesSignature]); const primaryFileId = activeFileIds[0] ?? null; - // Stable signature for effects (prevents loops) - const filesSignature = selectors.getFilesSignature(); - // UI state const globalProcessing = state.ui.isProcessing; diff --git a/frontend/src/components/shared/PageEditorFileDropdown.tsx b/frontend/src/components/shared/PageEditorFileDropdown.tsx index b5444ee9e..27d4229a5 100644 --- a/frontend/src/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/components/shared/PageEditorFileDropdown.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback, useState } from 'react'; +import React, { useRef, useCallback, useState, useEffect } from 'react'; import { Menu, Loader, Group, Text, Checkbox, ActionIcon } from '@mantine/core'; import EditNoteIcon from '@mui/icons-material/EditNote'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; @@ -12,7 +12,14 @@ import FitText from './FitText'; import { getFileColorWithOpacity } from '../pageEditor/fileColors'; import { FileId } from '../../types/file'; -import { PageEditorFile } from '../../contexts/PageEditorContext'; + +// Local interface for PageEditor file display +interface PageEditorFile { + fileId: FileId; + name: string; + versionNumber?: number; + isSelected: boolean; +} interface FileMenuItemProps { file: PageEditorFile; @@ -45,61 +52,79 @@ const FileMenuItem: React.FC = ({ const [isDragOver, setIsDragOver] = useState(false); const itemRef = useRef(null); - const itemElementRef = useCallback((element: HTMLDivElement | null) => { - if (element) { - itemRef.current = element; + // Keep latest values without re-registering DnD + const indexRef = useRef(index); + const fileIdRef = useRef(file.fileId); + useEffect(() => { indexRef.current = index; }, [index]); + useEffect(() => { fileIdRef.current = file.fileId; }, [file.fileId]); - const dragCleanup = draggable({ - element, - getInitialData: () => ({ - type: 'file-item', - fileId: file.fileId, - fromIndex: index, - }), - onDragStart: () => { - setIsDragging(true); - }, - onDrop: () => { - setIsDragging(false); - }, - canDrag: () => true, - }); + // NEW: keep latest onReorder without effect re-run + const onReorderRef = useRef(onReorder); + useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]); - const dropCleanup = dropTargetForElements({ - element, - getData: () => ({ - type: 'file-item', - fileId: file.fileId, - toIndex: index, - }), - onDragEnter: () => { - setIsDragOver(true); - }, - onDragLeave: () => { - setIsDragOver(false); - }, - onDrop: ({ source }) => { - setIsDragOver(false); - const sourceData = source.data; - if (sourceData.type === 'file-item') { - const fromIndex = sourceData.fromIndex as number; - if (fromIndex !== index) { - onReorder(fromIndex, index); - } + // Gesture guard for row click vs drag + const movedRef = useRef(false); + const startRef = useRef<{ x: number; y: number } | null>(null); + + const onPointerDown = (e: React.PointerEvent) => { + startRef.current = { x: e.clientX, y: e.clientY }; + movedRef.current = false; + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!startRef.current) return; + const dx = e.clientX - startRef.current.x; + const dy = e.clientY - startRef.current.y; + if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold + }; + + const onPointerUp = () => { + startRef.current = null; + }; + + useEffect(() => { + const element = itemRef.current; + if (!element) return; + + const dragCleanup = draggable({ + element, + getInitialData: () => ({ + type: 'file-item', + fileId: fileIdRef.current, + fromIndex: indexRef.current, + }), + onDragStart: () => setIsDragging((p) => (p ? p : true)), + onDrop: () => setIsDragging((p) => (p ? false : p)), + canDrag: () => true, + }); + + const dropCleanup = dropTargetForElements({ + element, + getData: () => ({ + type: 'file-item', + fileId: fileIdRef.current, + toIndex: indexRef.current, + }), + onDragEnter: () => setIsDragOver((p) => (p ? p : true)), + onDragLeave: () => setIsDragOver((p) => (p ? false : p)), + onDrop: ({ source }) => { + setIsDragOver(false); + const sourceData = source.data as any; + if (sourceData?.type === 'file-item') { + const fromIndex = sourceData.fromIndex as number; + const toIndex = indexRef.current; + if (fromIndex !== toIndex) { + onReorderRef.current(fromIndex, toIndex); // use ref, no re-register } } - }); - - (element as any).__dragCleanup = () => { - dragCleanup(); - dropCleanup(); - }; - } else { - if (itemRef.current && (itemRef.current as any).__dragCleanup) { - (itemRef.current as any).__dragCleanup(); } - } - }, [file.fileId, index, onReorder]); + }); + + return () => { + try { dragCleanup(); } catch {} + try { dropCleanup(); } catch {} + }; + }, []); // NOTE: no `onReorder` here const itemName = file?.name || 'Untitled'; const fileColorBorder = getFileColorWithOpacity(colorIndex, 1); @@ -107,9 +132,13 @@ const FileMenuItem: React.FC = ({ return (
{ e.stopPropagation(); + if (movedRef.current) return; // ignore click after drag onToggleSelection(file.fileId); }} style={{ diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index d2a79ce3c..2a64908da 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -1,6 +1,4 @@ import React, { useState, useCallback, useMemo } from "react"; -// Component to sync PageEditorContext with FileContext -// Must be inside PageEditorProvider to access usePageEditor import { SegmentedControl, Loader } from "@mantine/core"; import { useRainbowThemeContext } from "./RainbowThemeProvider"; import rainbowStyles from '../../styles/rainbow.module.css'; @@ -11,49 +9,63 @@ import { WorkbenchType, isValidWorkbench } from '../../types/workbench'; import { FileDropdownMenu } from './FileDropdownMenu'; import { PageEditorFileDropdown } from './PageEditorFileDropdown'; import { usePageEditor } from '../../contexts/PageEditorContext'; +import { useFileState } from '../../contexts/FileContext'; import { FileId } from '../../types/file'; +// Local interface for PageEditor file display +interface PageEditorFile { + fileId: FileId; + name: string; + versionNumber?: number; + isSelected: boolean; +} +interface PageEditorState { + files: PageEditorFile[]; + selectedCount: number; + totalCount: number; + onToggleSelection: (fileId: FileId) => void; + onReorder: (fromIndex: number, toIndex: number) => void; + fileColorMap: Map; +} + +// View option styling const viewOptionStyle: React.CSSProperties = { - display: 'inline-flex', - flexDirection: 'row', + display: 'flex', alignItems: 'center', - gap: 6, - whiteSpace: 'nowrap', - paddingTop: '0.3rem', + gap: '0.5rem', + justifyContent: 'center', }; - -// Build view options showing text always +// Helper function to create view options for SegmentedControl const createViewOptions = ( currentView: WorkbenchType, switchingTo: WorkbenchType | null, - activeFiles: Array<{ fileId: string | FileId; name: string; versionNumber?: number }>, - currentFileIndex: number, + activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>, + currentFileIndex?: number, onFileSelect?: (index: number) => void, - pageEditorState?: { - 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; - } + pageEditorState?: PageEditorState ) => { - const currentFile = activeFiles[currentFileIndex]; + // Viewer dropdown logic const isInViewer = currentView === 'viewer'; - const fileName = currentFile?.name || ''; - const displayName = isInViewer && fileName ? fileName : 'Viewer'; - const hasMultipleFiles = activeFiles.length > 1; - const showDropdown = isInViewer && hasMultipleFiles; + const hasActiveFiles = activeFiles && activeFiles.length > 0; + const showViewerDropdown = isInViewer && hasActiveFiles; + + let viewerDisplayName = 'Viewer'; + if (isInViewer && hasActiveFiles && currentFileIndex !== undefined) { + const currentFile = activeFiles[currentFileIndex]; + if (currentFile) { + viewerDisplayName = currentFile.name; + } + } const viewerOption = { - label: showDropdown ? ( + label: showViewerDropdown ? ( @@ -64,7 +76,7 @@ const createViewOptions = ( ) : ( )} - {displayName} + {viewerDisplayName}
), value: "viewer", @@ -149,12 +161,71 @@ const TopControls = ({ const { isRainbowMode } = useRainbowThemeContext(); const [switchingTo, setSwitchingTo] = useState(null); - // Get page editor state for dropdown + // Get FileContext state and PageEditor coordination functions + const { state, selectors } = useFileState(); + const pageEditorContext = usePageEditor(); const { - files: pageEditorFiles = [], toggleFileSelection, reorderFiles: pageEditorReorderFiles, - } = usePageEditor(); + fileOrder: pageEditorFileOrder, + } = pageEditorContext; + + // Derive page editor files from PageEditorContext.fileOrder (page editor workspace order) + // Filter to only show PDF files (PageEditor only supports PDFs) + // Use stable string keys to prevent infinite loops + // Cache file objects to prevent infinite re-renders from new object references + const fileOrderKey = pageEditorFileOrder.join(','); + const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(','); + const filesSignature = selectors.getFilesSignature(); + + const fileObjectsRef = React.useRef(new Map()); + + const pageEditorFiles = useMemo(() => { + const cache = fileObjectsRef.current; + const newFiles: PageEditorFile[] = []; + + // Use PageEditorContext.fileOrder instead of state.files.ids + pageEditorFileOrder.forEach(fileId => { + const stub = selectors.getStirlingFileStub(fileId); + const isSelected = state.ui.selectedFileIds.includes(fileId); + const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false; + + if (!isPdf) return; // Skip non-PDFs + + const cached = cache.get(fileId); + + // Check if data actually changed (compare by fileId, not position) + if (cached && + cached.fileId === fileId && + cached.name === (stub?.name || '') && + cached.versionNumber === stub?.versionNumber && + cached.isSelected === isSelected) { + // Reuse existing object reference + newFiles.push(cached); + } else { + // Create new object only if data changed + const newFile: PageEditorFile = { + fileId, + name: stub?.name || '', + versionNumber: stub?.versionNumber, + isSelected, + }; + cache.set(fileId, newFile); + newFiles.push(newFile); + } + }); + + // Clean up removed files from cache + const activeIds = new Set(newFiles.map(f => f.fileId)); + for (const cachedId of cache.keys()) { + if (!activeIds.has(cachedId)) { + cache.delete(cachedId); + } + } + + return newFiles; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileOrderKey, selectedIdsKey, filesSignature, pageEditorFileOrder, state.ui.selectedFileIds, selectors]); // Convert to counts const selectedCount = pageEditorFiles?.filter(f => f.isSelected).length || 0; diff --git a/frontend/src/contexts/PageEditorContext.tsx b/frontend/src/contexts/PageEditorContext.tsx index a73d94c38..6e7c4329c 100644 --- a/frontend/src/contexts/PageEditorContext.tsx +++ b/frontend/src/contexts/PageEditorContext.tsx @@ -1,15 +1,11 @@ -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo } from 'react'; import { FileId } from '../types/file'; -import { useFileActions } from './FileContext'; +import { useFileActions, useFileState } 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; -} +// PageEditorFile is now defined locally in consuming components +// Components should derive file list directly from FileContext /** * Computes file order based on the position of each file's first page @@ -103,9 +99,6 @@ function reorderPagesForFileMove( } interface PageEditorContextValue { - // Single array of files with selection state - files: PageEditorFile[]; - // Current page order (updated by PageEditor, used for file reordering) currentPages: PDFPage[] | null; updateCurrentPages: (pages: PDFPage[] | null) => void; @@ -114,45 +107,64 @@ interface PageEditorContextValue { reorderedPages: PDFPage[] | null; clearReorderedPages: () => void; - // Set file selection + // Page editor's own file order (independent of FileContext global order) + fileOrder: FileId[]; + setFileOrder: (order: FileId[]) => void; + + // Set file selection (calls FileContext actions) setFileSelection: (fileId: FileId, selected: boolean) => void; - // Toggle file selection + // Toggle file selection (calls FileContext actions) toggleFileSelection: (fileId: FileId) => void; - // Select/deselect all files + // Select/deselect all files (calls FileContext actions) selectAll: () => void; deselectAll: () => void; - // Reorder files (simple array reordering) + // Reorder files (only affects page editor's local order) 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: (fileContextFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>) => void; } const PageEditorContext = createContext(undefined); interface PageEditorProviderProps { children: ReactNode; - initialFileIds?: FileId[]; } -export function PageEditorProvider({ children, initialFileIds = [] }: PageEditorProviderProps) { - // Single array of files with selection state - const [files, setFiles] = useState([]); +export function PageEditorProvider({ children }: PageEditorProviderProps) { const [currentPages, setCurrentPages] = useState(null); const [reorderedPages, setReorderedPages] = useState(null); - const [lastReorderSource, setLastReorderSource] = useState<'file' | 'page' | null>(null); - const lastReorderSourceAtRef = React.useRef(0); + + // Page editor's own file order (independent of FileContext) + const [fileOrder, setFileOrder] = useState([]); + + // Read from FileContext (for file metadata only, not order) const { actions: fileActions } = useFileActions(); + const { state } = useFileState(); + + // Keep a ref to always read latest state in stable callbacks + const stateRef = React.useRef(state); + React.useEffect(() => { + stateRef.current = state; + }, [state]); + + // Initialize fileOrder from FileContext when files change (add/remove only) + React.useEffect(() => { + const currentFileIds = state.files.ids; + + // Add new files to the end + const newFileIds = currentFileIds.filter(id => !fileOrder.includes(id)); + + // Remove deleted files + const validFileOrder = fileOrder.filter(id => currentFileIds.includes(id)); + + if (newFileIds.length > 0 || validFileOrder.length !== fileOrder.length) { + setFileOrder([...validFileOrder, ...newFileIds]); + } + }, [state.files.ids, fileOrder]); const updateCurrentPages = useCallback((pages: PDFPage[] | null) => { setCurrentPages(pages); @@ -162,198 +174,132 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor setReorderedPages(null); }, []); - const clearReorderSource = useCallback(() => { - setLastReorderSource(null); - }, []); - const setFileSelection = useCallback((fileId: FileId, selected: boolean) => { - setFiles(prev => { - const selectedCount = prev.filter(f => f.isSelected).length; + const currentSelection = stateRef.current.ui.selectedFileIds; + const isAlreadySelected = currentSelection.includes(fileId); - // 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; - } - } - - return prev.map(f => - f.fileId === fileId ? { ...f, isSelected: selected } : f - ); - }); - }, []); - - 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(() => { - setFiles(prev => prev.map(f => ({ ...f, isSelected: false }))); - }, []); - - const reorderFiles = useCallback((fromIndex: number, toIndex: number) => { - let newFileIds: FileId[] = []; - let reorderedPagesResult: PDFPage[] | null = null; - - // Mark that this reorder came from file-level action - setLastReorderSource('file'); - lastReorderSourceAtRef.current = Date.now(); - - 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) { + // Check if we're trying to select when at limit + if (selected && !isAlreadySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) { + console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`); return; } - setLastReorderSource('page'); - lastReorderSourceAtRef.current = Date.now(); + // Update FileContext selection + const newSelectedIds = selected + ? [...currentSelection, fileId] + : currentSelection.filter(id => id !== fileId); + + fileActions.setSelectedFiles(newSelectedIds); + }, [fileActions]); + + const toggleFileSelection = useCallback((fileId: FileId) => { + const currentSelection = stateRef.current.ui.selectedFileIds; + const isCurrentlySelected = currentSelection.includes(fileId); + + // If toggling on and at limit, don't allow + if (!isCurrentlySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) { + console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`); + return; + } + + // Update FileContext selection + const newSelectedIds = isCurrentlySelected + ? currentSelection.filter(id => id !== fileId) + : [...currentSelection, fileId]; + + fileActions.setSelectedFiles(newSelectedIds); + }, [fileActions]); + + const selectAll = useCallback(() => { + const allFileIds = stateRef.current.files.ids; + + if (allFileIds.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.`); + fileActions.setSelectedFiles(allFileIds.slice(0, MAX_PAGE_EDITOR_FILES)); + } else { + fileActions.setSelectedFiles(allFileIds); + } + }, [fileActions]); + + const deselectAll = useCallback(() => { + fileActions.setSelectedFiles([]); + }, [fileActions]); + + const reorderFiles = useCallback((fromIndex: number, toIndex: number) => { + // Reorder local fileOrder array (page editor workspace only) + const newOrder = [...fileOrder]; + const [movedFileId] = newOrder.splice(fromIndex, 1); + newOrder.splice(toIndex, 0, movedFileId); + setFileOrder(newOrder); + + // 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 = fileOrder[fromIndex]; + const targetFileId = fileOrder[toIndex]; + + // 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) { + const reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder); + setReorderedPages(reorderedPagesResult); + } + } + }, [fileOrder, currentPages]); + + const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => { + if (!pages || pages.length === 0) return; // Compute the new file order based on page positions const newFileOrder = computeFileOrderFromPages(pages); if (newFileOrder.length > 0) { - // Update global FileContext order - fileActions.reorderFiles(newFileOrder); + // Update local page editor file order (not FileContext) + setFileOrder(newFileOrder); } - }, [fileActions, lastReorderSource]); - - 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) - 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 newFiles.map((f, index) => ({ - ...f, - isSelected: index < maxToSelect, - })); - } - - // Enforce maximum file limit - if (selectedCount > MAX_PAGE_EDITOR_FILES) { - console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Limiting selection.`); - let selectedSoFar = 0; - return newFiles.map(f => ({ - ...f, - isSelected: f.isSelected && selectedSoFar++ < MAX_PAGE_EDITOR_FILES, - })); - } - - return newFiles; - }); }, []); - const value: PageEditorContextValue = { - files, + + const value: PageEditorContextValue = useMemo(() => ({ currentPages, updateCurrentPages, reorderedPages, clearReorderedPages, + fileOrder, + setFileOrder, setFileSelection, toggleFileSelection, selectAll, deselectAll, reorderFiles, updateFileOrderFromPages, - lastReorderSource, - clearReorderSource, - syncWithFileContext, - }; + }), [ + currentPages, + updateCurrentPages, + reorderedPages, + clearReorderedPages, + fileOrder, + setFileSelection, + toggleFileSelection, + selectAll, + deselectAll, + reorderFiles, + updateFileOrderFromPages, + ]); return ( diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 3811572c0..92fb4448d 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -149,17 +149,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA // Validate that all IDs exist in current state const validIds = orderedFileIds.filter(id => state.files.byId[id]); - // Reorder selected files by passed order - const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id)); + + // Don't touch selectedFileIds - it's just a reference list, order doesn't matter return { ...state, files: { ...state.files, ids: validIds - }, - ui: { - ...state.ui, - selectedFileIds, } }; }