diff --git a/frontend/src/core/components/pageEditor/DragDropGrid.tsx b/frontend/src/core/components/pageEditor/DragDropGrid.tsx index 8f8598c29..47225c19f 100644 --- a/frontend/src/core/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/core/components/pageEditor/DragDropGrid.tsx @@ -33,10 +33,11 @@ interface DragDropItem { interface DragDropGridProps { items: T[]; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; - renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode; getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; zoomLevel?: number; selectedFileIds?: string[]; + selectedPageIds?: string[]; onVisibleItemsChange?: (items: T[]) => void; } @@ -189,17 +190,16 @@ interface DraggableItemProps { itemRefs: React.MutableRefObject>; boxSelectedPageIds: string[]; clearBoxSelection: () => void; - getBoxSelection: () => string[]; - activeId: string | null; activeDragIds: string[]; justMoved: boolean; getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; onUpdateDropTarget: (itemId: string | null) => void; - renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode; zoomLevel: number; + selectedPageIds?: string[]; } -const DraggableItemInner = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps) => { +const DraggableItemInner = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps) => { const isPlaceholder = Boolean(item.isPlaceholder); const pageNumber = (item as any).pageNumber ?? index + 1; const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ @@ -248,7 +248,7 @@ const DraggableItemInner = ({ item, index, itemRefs, box return ( <> - {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)} + {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)} ); }; @@ -266,11 +266,22 @@ const DraggableItem = React.memo(DraggableItemInner, (prevProps, nextProps) => { return false; // Props changed, re-render needed } + // Check if page selection changed (for checkbox selection, not box selection) + const prevSelectedSet = prevProps.selectedPageIds ? new Set(prevProps.selectedPageIds) : null; + const nextSelectedSet = nextProps.selectedPageIds ? new Set(nextProps.selectedPageIds) : null; + + if (prevSelectedSet && nextSelectedSet) { + const prevSelected = prevSelectedSet.has(prevProps.item.id); + const nextSelected = nextSelectedSet.has(nextProps.item.id); + if (prevSelected !== nextSelected) { + return false; // Selection state changed for this item, re-render needed + } + } + // Item reference is same, check other props return ( prevProps.item.id === nextProps.item.id && prevProps.index === nextProps.index && - prevProps.activeId === nextProps.activeId && prevProps.justMoved === nextProps.justMoved && prevProps.zoomLevel === nextProps.zoomLevel && prevProps.activeDragIds.length === nextProps.activeDragIds.length && @@ -285,6 +296,7 @@ const DragDropGrid = ({ getThumbnailData, zoomLevel = 1.0, selectedFileIds, + selectedPageIds, onVisibleItemsChange, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); @@ -294,6 +306,10 @@ const DragDropGrid = ({ return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null; }, []); + // Create stable signature for items to ensure useMemo detects changes + const itemsSignature = useMemo(() => items.map(item => item.id).join(','), [items]); + const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(',') || '', [selectedFileIds]); + const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => { const filtered: T[] = []; const indexMap: number[] = []; @@ -318,7 +334,7 @@ const DragDropGrid = ({ }); return { filteredItems: filtered, filteredToOriginalIndex: indexMap }; - }, [items, selectedFileIds]); + }, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]); useEffect(() => { const visibleIdSet = new Set(visibleItems.map(item => item.id)); @@ -464,9 +480,11 @@ const DragDropGrid = ({ }, [virtualRows, visibleItems, itemsPerRow, onVisibleItemsChange]); // Re-measure virtualizer when zoom or items per row changes + // Also remeasure when items change (not just length) to handle item additions/removals + const visibleItemsSignature = useMemo(() => visibleItems.map(item => item.id).join(','), [visibleItems]); useEffect(() => { rowVirtualizer.measure(); - }, [zoomLevel, itemsPerRow, visibleItems.length]); + }, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]); // Cleanup highlight timeout on unmount useEffect(() => { @@ -565,11 +583,6 @@ const DragDropGrid = ({ setBoxSelectedPageIds([]); }, []); - // Function to get current box selection (exposed to child components) - const getBoxSelection = useCallback(() => { - return boxSelectedPageIds; - }, [boxSelectedPageIds]); - // Handle drag start const handleDragStart = useCallback((event: DragStartEvent) => { const activeId = event.active.id as string; @@ -791,14 +804,13 @@ const DragDropGrid = ({ itemRefs={itemRefs} boxSelectedPageIds={boxSelectedPageIds} clearBoxSelection={clearBoxSelection} - getBoxSelection={getBoxSelection} - activeId={activeId} activeDragIds={activeDragIds} justMoved={justMovedIds.includes(item.id)} getThumbnailData={getThumbnailData} onUpdateDropTarget={setHoveredItemId} renderItem={renderItem} zoomLevel={zoomLevel} + selectedPageIds={selectedPageIds} /> ); })} diff --git a/frontend/src/core/components/pageEditor/PageEditor.tsx b/frontend/src/core/components/pageEditor/PageEditor.tsx index 6c8f554ec..b182c9d3a 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/src/core/components/pageEditor/PageEditor.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { useFileState, useFileActions } from "@app/contexts/FileContext"; -import { useNavigationGuard } from "@app/contexts/NavigationContext"; +import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext"; import { usePageEditor } from "@app/contexts/PageEditorContext"; import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor"; // Thumbnail generation is now handled by individual PageThumbnail components @@ -24,6 +24,7 @@ import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePag import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands"; import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport"; import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration"; +import { convertSplitPageIdsToIndexes } from '@app/components/pageEditor/utils/splitPositions'; export interface PageEditorProps { onFunctionsReady?: (functions: PageEditorFunctions) => void; @@ -39,6 +40,7 @@ const PageEditor = ({ // Navigation guard for unsaved changes const { setHasUnsavedChanges } = useNavigationGuard(); + const navigationState = useNavigationState(); // Get PageEditor coordination functions const { @@ -48,6 +50,7 @@ const PageEditor = ({ clearReorderedPages, updateCurrentPages, savePersistedDocument, + clearPersistedDocument, } = usePageEditor(); const [visiblePageIds, setVisiblePageIds] = useState([]); @@ -175,15 +178,136 @@ const PageEditor = ({ displayDocumentRef.current = displayDocument; }, [displayDocument]); + const queueThumbnailRequestsForPages = useCallback((pageIds: string[]) => { + const doc = displayDocumentRef.current; + if (!doc || pageIds.length === 0) return; + + const loadedCount = doc.pages.filter(p => p.thumbnail).length; + const pending = thumbnailRequestsRef.current.size; + const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1 + : doc.totalPages < 20 ? 3 + : doc.totalPages < 50 ? 5 + : 8; + const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending); + if (available === 0) return; + + const toLoad: string[] = []; + for (const pageId of pageIds) { + if (toLoad.length >= available) break; + if (thumbnailRequestsRef.current.has(pageId)) continue; + const page = doc.pages.find(p => p.id === pageId); + if (!page || page.thumbnail) continue; + toLoad.push(pageId); + } + + if (toLoad.length === 0) return; + + toLoad.forEach(pageId => { + const page = doc.pages.find(p => p.id === pageId); + if (!page) return; + + const cached = getThumbnailFromCache(pageId); + if (cached) { + thumbnailRequestsRef.current.add(pageId); + Promise.resolve(cached) + .then(cache => { + setEditedDocument(prev => { + if (!prev) return prev; + const pageIndex = prev.pages.findIndex(p => p.id === pageId); + if (pageIndex === -1) return prev; + + const updated = [...prev.pages]; + updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache }; + return { ...prev, pages: updated }; + }); + }) + .finally(() => { + thumbnailRequestsRef.current.delete(pageId); + }); + return; + } + + const fileId = page.originalFileId; + if (!fileId) return; + const file = selectors.getFile(fileId); + if (!file) return; + + thumbnailRequestsRef.current.add(pageId); + requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber) + .then(thumbnail => { + if (thumbnail) { + setEditedDocument(prev => { + if (!prev) return prev; + const pageIndex = prev.pages.findIndex(p => p.id === pageId); + if (pageIndex === -1) return prev; + + const updated = [...prev.pages]; + updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail }; + return { ...prev, pages: updated }; + }); + } + }) + .catch((error) => { + console.error('[Thumbnail Loading] Error:', error); + }) + .finally(() => { + thumbnailRequestsRef.current.delete(pageId); + }); + }); + }, [ + getThumbnailFromCache, + requestThumbnail, + selectors, + setEditedDocument + ]); + + useEffect(() => { + if (!displayDocument) { + return; + } + queueThumbnailRequestsForPages(visiblePageIds); + }, [displayDocument, visiblePageIds, queueThumbnailRequestsForPages]); + + const lastInitialDocumentSignatureRef = useRef(null); + const displayDocumentId = displayDocument?.id ?? null; + const displayDocumentLength = displayDocument?.pages.length ?? 0; + useEffect(() => { + if (!displayDocument || displayDocument.pages.length === 0) { + lastInitialDocumentSignatureRef.current = null; + return; + } + + const signature = `${displayDocumentId}:${displayDocumentLength}`; + if (lastInitialDocumentSignatureRef.current === signature) { + return; + } + + const INITIAL_VISIBLE_PAGE_COUNT = 8; + const initialIds = displayDocument.pages + .slice(0, INITIAL_VISIBLE_PAGE_COUNT) + .map(page => page.id); + + queueThumbnailRequestsForPages(initialIds); + lastInitialDocumentSignatureRef.current = signature; + }, [displayDocumentId, displayDocumentLength, queueThumbnailRequestsForPages]); + + useEffect(() => { + setVisiblePageIds([]); + }, [displayDocumentId]); + useEffect(() => { return () => { + if (navigationState.workbench !== 'pageEditor') { + return; + } + const doc = displayDocumentRef.current; if (doc && doc.pages.length > 0) { const signature = doc.pages.map(page => page.id).join(','); savePersistedDocument(doc, signature); } }; - }, [savePersistedDocument]); + }, [savePersistedDocument, navigationState.workbench]); // UI state management const { @@ -265,94 +389,10 @@ const PageEditor = ({ exportLoading, setExportLoading, setSplitPositions, + clearPersistedDocument, + updateCurrentPages, }); - useEffect(() => { - if (!displayDocument || visiblePageIds.length === 0) { - return; - } - - const pending = thumbnailRequestsRef.current.size; - const MAX_CONCURRENT_THUMBNAILS = 12; - const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending); - if (available === 0) { - return; - } - - const toLoad: string[] = []; - for (const pageId of visiblePageIds) { - if (toLoad.length >= available) break; - if (thumbnailRequestsRef.current.has(pageId)) continue; - const page = displayDocument.pages.find(p => p.id === pageId); - if (!page || page.thumbnail) continue; - toLoad.push(pageId); - } - - if (toLoad.length === 0) return; - - toLoad.forEach(pageId => { - const page = displayDocument.pages.find(p => p.id === pageId); - if (!page) return; - - const cached = getThumbnailFromCache(pageId); - if (cached) { - thumbnailRequestsRef.current.add(pageId); - Promise.resolve(cached) - .then(cache => { - setEditedDocument(prev => { - if (!prev) return prev; - const pageIndex = prev.pages.findIndex(p => p.id === pageId); - if (pageIndex === -1) return prev; - - // Only create new page object for the changed page, reuse rest - const updated = [...prev.pages]; - updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache }; - return { ...prev, pages: updated }; - }); - }) - .finally(() => { - thumbnailRequestsRef.current.delete(pageId); - }); - return; - } - - const fileId = page.originalFileId; - if (!fileId) return; - const file = selectors.getFile(fileId); - if (!file) return; - - thumbnailRequestsRef.current.add(pageId); - requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber) - .then(thumbnail => { - if (thumbnail) { - setEditedDocument(prev => { - if (!prev) return prev; - const pageIndex = prev.pages.findIndex(p => p.id === pageId); - if (pageIndex === -1) return prev; - - // Only create new page object for the changed page, reuse rest - const updated = [...prev.pages]; - updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail }; - return { ...prev, pages: updated }; - }); - } - }) - .catch((error) => { - console.error('[Thumbnail Loading] Error:', error); - }) - .finally(() => { - thumbnailRequestsRef.current.delete(pageId); - }); - }); - }, [ - displayDocument, - visiblePageIds, - selectors, - requestThumbnail, - getThumbnailFromCache, - setEditedDocument, - ]); - // Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument) const selectedPageCount = selectedPageIds.length; const activeFileIds = selectedFileIds; @@ -466,6 +506,78 @@ const PageEditor = ({ // Track color assignments by insertion order (files keep their color) const fileColorIndexMap = useFileColorMap(orderedFileIds); + // Memoize renderItem to prevent DragDropGrid's React.memo from blocking updates + // when selectedPageIds changes + const renderItemCallback = useCallback(( + page: PDFPage, + index: number, + refs: React.MutableRefObject>, + boxSelectedIds: string[], + clearBoxSelection: () => void, + activeDragIds: string[], + justMoved: boolean, + dragHandleProps?: any, + zoomLevelParam?: number + ) => { + gridItemRefsRef.current = refs; + const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0; + const isBoxSelected = boxSelectedIds.includes(page.id); + return ( + {}} + onSetMovingPage={setMovingPage} + onDeletePage={handleDeletePage} + createRotateCommand={createRotateCommand} + createDeleteCommand={createDeleteCommand} + createSplitCommand={createSplitCommand} + pdfDocument={displayDocument!} + setPdfDocument={setEditedDocument} + splitPositions={splitPositions} + onInsertFiles={handleInsertFiles} + zoomLevel={zoomLevelParam || zoomLevel} + /> + ); + }, [ + selectedPageIds, + selectionMode, + movingPage, + isAnimating, + displayDocument, + fileColorIndexMap, + handleReorderPages, + togglePage, + animateReorder, + executeCommand, + setMovingPage, + handleDeletePage, + createRotateCommand, + createDeleteCommand, + createSplitCommand, + setEditedDocument, + splitPositions, + handleInsertFiles, + zoomLevel, + ]); + return (
- {/* Split Lines Overlay */}
{ + return Array.from(splitIndexes).map((position) => { const currentPage = displayedPages[position]; if (!currentPage) { return null; @@ -577,6 +689,7 @@ const PageEditor = ({ onReorderPages={handleReorderPages} zoomLevel={zoomLevel} selectedFileIds={selectedFileIds} + selectedPageIds={selectedPageIds} onVisibleItemsChange={handleVisibleItemsChange} getThumbnailData={(pageId) => { const page = displayDocument.pages.find(p => p.id === pageId); @@ -586,50 +699,11 @@ const PageEditor = ({ rotation: page.rotation || 0 }; }} - renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, _getBoxSelection, _activeId, activeDragIds, justMoved, _isOver, dragHandleProps, zoomLevel) => { - gridItemRefsRef.current = refs; - const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0; - const isBoxSelected = boxSelectedIds.includes(page.id); - return ( - {}} - onSetMovingPage={setMovingPage} - onDeletePage={handleDeletePage} - createRotateCommand={createRotateCommand} - createDeleteCommand={createDeleteCommand} - createSplitCommand={createSplitCommand} - pdfDocument={displayDocument} - setPdfDocument={setEditedDocument} - splitPositions={splitPositions} - onInsertFiles={handleInsertFiles} - zoomLevel={zoomLevel} - /> - ); - }} + renderItem={renderItemCallback} /> )} - { await applyChanges(); @@ -638,7 +712,6 @@ const PageEditor = ({ await onExportAll(); }} /> -
); }; diff --git a/frontend/src/core/components/pageEditor/PageEditorControls.tsx b/frontend/src/core/components/pageEditor/PageEditorControls.tsx index 9360349f6..8649076d4 100644 --- a/frontend/src/core/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/core/components/pageEditor/PageEditorControls.tsx @@ -38,7 +38,7 @@ interface PageEditorControlsProps { displayDocument?: { pages: { id: string; pageNumber: number }[] }; // Split state (for tooltip logic) - splitPositions?: Set; + splitPositions?: Set; totalPages?: number; } @@ -54,35 +54,29 @@ const PageEditorControls = ({ selectedPageIds, displayDocument, splitPositions, - totalPages }: PageEditorControlsProps) => { // Calculate split tooltip text using smart toggle logic const getSplitTooltip = () => { - if (!splitPositions || !totalPages || selectedPageIds.length === 0) { + if (!splitPositions || !displayDocument || selectedPageIds.length === 0) { return "Split Selected"; } - // Convert selected pages to split positions (same logic as handleSplit) - const selectedPageNumbers = displayDocument ? selectedPageIds.map(id => { - const page = displayDocument.pages.find(p => p.id === id); - return page?.pageNumber || 0; - }).filter(num => num > 0) : []; - const selectedSplitPositions = selectedPageNumbers.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1); + const totalPages = displayDocument.pages.length; + const selectedValidPageIds = displayDocument.pages + .filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1) + .map(page => page.id); - if (selectedSplitPositions.length === 0) { + if (selectedValidPageIds.length === 0) { return "Split Selected"; } - // Smart toggle logic: follow the majority, default to adding splits if equal - const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length; - const noSplitsCount = selectedSplitPositions.length - existingSplitsCount; + const existingSplitsCount = selectedValidPageIds.filter(id => splitPositions.has(id)).length; + const noSplitsCount = selectedValidPageIds.length - existingSplitsCount; - // Remove splits only if majority already have splits - // If equal (50/50), default to adding splits const willRemoveSplits = existingSplitsCount > noSplitsCount; if (willRemoveSplits) { - return existingSplitsCount === selectedSplitPositions.length + return existingSplitsCount === selectedValidPageIds.length ? "Remove All Selected Splits" : "Remove Selected Splits"; } else { diff --git a/frontend/src/core/components/pageEditor/PageThumbnail.tsx b/frontend/src/core/components/pageEditor/PageThumbnail.tsx index 851b0a8d5..87adbf90e 100644 --- a/frontend/src/core/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/core/components/pageEditor/PageThumbnail.tsx @@ -41,10 +41,10 @@ interface PageThumbnailProps { onDeletePage: (pageNumber: number) => void; createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void }; createDeleteCommand: (pageIds: string[]) => { execute: () => void }; - createSplitCommand: (position: number) => { execute: () => void }; + createSplitCommand: (pageId: string, pageNumber: number) => { execute: () => void }; pdfDocument: PDFDocument; setPdfDocument: (doc: PDFDocument) => void; - splitPositions: Set; + splitPositions: Set; onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void; zoomLevel?: number; } @@ -78,6 +78,7 @@ const PageThumbnail: React.FC = ({ justMoved = false, }: PageThumbnailProps) => { const pageIndex = page.pageNumber - 1; + const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false; const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); @@ -155,10 +156,10 @@ const PageThumbnail: React.FC = ({ e.stopPropagation(); // Create a command to toggle split at this position - const command = createSplitCommand(pageIndex); + const command = createSplitCommand(page.id, page.pageNumber); onExecuteCommand(command); - const hasSplit = splitPositions.has(pageIndex); + const hasSplit = splitPositions.has(page.id); const action = hasSplit ? 'removed' : 'added'; onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`); }, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]); @@ -365,7 +366,7 @@ const PageThumbnail: React.FC = ({ }} > { // Selection is handled by container mouseDown }} diff --git a/frontend/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/src/core/components/pageEditor/commands/pageCommands.ts index 536f1d667..4f747c4d7 100644 --- a/frontend/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/core/components/pageEditor/commands/pageCommands.ts @@ -57,7 +57,7 @@ export class RotatePageCommand extends DOMCommand { export class DeletePagesCommand extends DOMCommand { private originalDocument: PDFDocument | null = null; - private originalSplitPositions: Set = new Set(); + private originalSplitPositions: Set = new Set(); private originalSelectedPages: number[] = []; private hasExecuted: boolean = false; private pageIdsToDelete: string[] = []; @@ -68,8 +68,8 @@ export class DeletePagesCommand extends DOMCommand { private getCurrentDocument: () => PDFDocument | null, private setDocument: (doc: PDFDocument) => void, private setSelectedPageIds: (pageIds: string[]) => void, - private getSplitPositions: () => Set, - private setSplitPositions: (positions: Set) => void, + private getSplitPositions: () => Set, + private setSplitPositions: (positions: Set) => void, private getSelectedPages: () => number[], onAllPagesDeleted?: () => void ) { @@ -133,10 +133,15 @@ export class DeletePagesCommand extends DOMCommand { // Adjust split positions const currentSplitPositions = this.getSplitPositions(); - const newPositions = new Set(); - currentSplitPositions.forEach(pos => { - if (pos < remainingPages.length - 1) { - newPositions.add(pos); + const remainingIndexMap = new Map(); + remainingPages.forEach((page, index) => { + remainingIndexMap.set(page.id, index); + }); + const newPositions = new Set(); + currentSplitPositions.forEach((pageId) => { + const splitIndex = remainingIndexMap.get(pageId); + if (splitIndex !== undefined && splitIndex < remainingPages.length - 1) { + newPositions.add(pageId); } }); @@ -261,12 +266,13 @@ export class ReorderPagesCommand extends DOMCommand { } export class SplitCommand extends DOMCommand { - private originalSplitPositions: Set = new Set(); + private originalSplitPositions: Set = new Set(); constructor( - private position: number, - private getSplitPositions: () => Set, - private setSplitPositions: (positions: Set) => void + private pageId: string, + private pageNumber: number, + private getSplitPositions: () => Set, + private setSplitPositions: (positions: Set) => void ) { super(); } @@ -279,10 +285,10 @@ export class SplitCommand extends DOMCommand { const currentPositions = this.getSplitPositions(); const newPositions = new Set(currentPositions); - if (newPositions.has(this.position)) { - newPositions.delete(this.position); + if (newPositions.has(this.pageId)) { + newPositions.delete(this.pageId); } else { - newPositions.add(this.position); + newPositions.add(this.pageId); } this.setSplitPositions(newPositions); @@ -295,8 +301,8 @@ export class SplitCommand extends DOMCommand { get description(): string { const currentPositions = this.getSplitPositions(); - const willAdd = !currentPositions.has(this.position); - return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`; + const willAdd = !currentPositions.has(this.pageId); + return `${willAdd ? 'Add' : 'Remove'} split at position ${this.pageNumber}`; } } diff --git a/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts b/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts index 10c0cacf9..9cc7275f6 100644 --- a/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts +++ b/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts @@ -78,6 +78,12 @@ export const useEditedDocumentState = ({ } }, [mergedPdfDocument]); + useEffect(() => { + if (!mergedPdfDocument) { + setEditedDocument(null); + } + }, [mergedPdfDocument, setEditedDocument]); + // Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing) useEffect(() => { const currentEditedDocument = editedDocumentRef.current; @@ -101,6 +107,8 @@ export const useEditedDocumentState = ({ const sourcePages = mergedPdfDocument.pages; const sourceIds = new Set(sourcePages.map((p) => p.id)); const prevIds = new Set(prev.pages.map((p) => p.id)); + const hasOverlap = sourcePages.some((page) => prevIds.has(page.id)); + const shouldResetToMerged = !hasOverlap; const newPages: PDFPage[] = []; for (const page of sourcePages) { @@ -121,7 +129,9 @@ export const useEditedDocumentState = ({ } } - if (hasAdditions || hasRemovals) { + if (shouldResetToMerged) { + pages = sourcePages.map((page) => ({ ...page })); + } else if (hasAdditions || hasRemovals) { pages = [...prev.pages]; const placeholderPositions = new Map(); @@ -194,6 +204,9 @@ export const useEditedDocumentState = ({ }); } + } + + if (shouldResetToMerged || hasAdditions || hasRemovals) { pages = pages.map((page, index) => ({ ...page, pageNumber: index + 1, diff --git a/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts b/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts index 45253492f..a1f6e4f7c 100644 --- a/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts +++ b/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { BulkRotateCommand, @@ -22,8 +22,8 @@ interface UsePageEditorCommandsParams { displayDocument: PDFDocument | null; getEditedDocument: () => PDFDocument | null; setEditedDocument: React.Dispatch>; - splitPositions: Set; - setSplitPositions: React.Dispatch>>; + splitPositions: Set; + setSplitPositions: React.Dispatch>>; selectedPageIds: string[]; setSelectedPageIds: (ids: string[]) => void; getPageNumbersFromIds: (pageIds: string[]) => number[]; @@ -51,6 +51,12 @@ export const usePageEditorCommands = ({ setSelectionMode, clearUndoHistory, }: UsePageEditorCommandsParams) => { + const splitPositionsRef = useRef(splitPositions); + + useEffect(() => { + splitPositionsRef.current = splitPositions; + }, [splitPositions]); + const closePdf = useCallback(() => { actions.clearAllFiles(); clearUndoHistory(); @@ -118,17 +124,18 @@ export const usePageEditorCommands = ({ ); const createSplitCommand = useCallback( - (position: number) => ({ + (pageId: string, pageNumber: number) => ({ execute: () => { const splitCommand = new SplitCommand( - position, - () => splitPositions, + pageId, + pageNumber, + () => splitPositionsRef.current, setSplitPositions ); executeCommandWithTracking(splitCommand); }, }), - [splitPositions, executeCommandWithTracking, setSplitPositions] + [executeCommandWithTracking, setSplitPositions] ); const executeCommand = useCallback((command: any) => { @@ -208,39 +215,34 @@ export const usePageEditorCommands = ({ const handleSplit = useCallback(() => { if (!displayDocument || selectedPageIds.length === 0) return; - const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); - const selectedPositions: number[] = []; - selectedPageNumbers.forEach((pageNum) => { - const pageIndex = displayDocument.pages.findIndex( - (p) => p.pageNumber === pageNum - ); - if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { - selectedPositions.push(pageIndex); - } - }); + const selectedSplitPageIds = displayDocument.pages + .filter((page, index) => + selectedPageIds.includes(page.id) && index < displayDocument.pages.length - 1 + ) + .map((page) => page.id); - if (selectedPositions.length === 0) return; + if (selectedSplitPageIds.length === 0) return; - const existingSplitsCount = selectedPositions.filter((pos) => - splitPositions.has(pos) + const existingSplitsCount = selectedSplitPageIds.filter((id) => + splitPositions.has(id) ).length; - const noSplitsCount = selectedPositions.length - existingSplitsCount; + const noSplitsCount = selectedSplitPageIds.length - existingSplitsCount; const shouldRemoveSplits = existingSplitsCount > noSplitsCount; const newSplitPositions = new Set(splitPositions); if (shouldRemoveSplits) { - selectedPositions.forEach((pos) => newSplitPositions.delete(pos)); + selectedSplitPageIds.forEach((id) => newSplitPositions.delete(id)); } else { - selectedPositions.forEach((pos) => newSplitPositions.add(pos)); + selectedSplitPageIds.forEach((id) => newSplitPositions.add(id)); } const smartSplitCommand = { execute: () => setSplitPositions(newSplitPositions), undo: () => setSplitPositions(splitPositions), description: shouldRemoveSplits - ? `Remove ${selectedPositions.length} split(s)` - : `Add ${selectedPositions.length - existingSplitsCount} split(s)`, + ? `Remove ${selectedSplitPageIds.length} split(s)` + : `Add ${selectedSplitPageIds.length - existingSplitsCount} split(s)`, }; executeCommandWithTracking(smartSplitCommand); @@ -283,16 +285,36 @@ export const usePageEditorCommands = ({ insertAfterPage: number, isFromStorage?: boolean ) => { + console.log('[PageEditor] handleInsertFiles called:', { + fileCount: files.length, + insertAfterPage, + isFromStorage, + }); + const workingDocument = getEditedDocument(); - if (!workingDocument || files.length === 0) return; + if (!workingDocument || files.length === 0) { + console.log('[PageEditor] handleInsertFiles early return:', { + hasDocument: !!workingDocument, + fileCount: files.length, + }); + return; + } try { const targetPage = workingDocument.pages.find( (p) => p.pageNumber === insertAfterPage ); - if (!targetPage) return; + if (!targetPage) { + console.log('[PageEditor] Target page not found:', insertAfterPage); + return; + } const insertAfterPageId = targetPage.id; + console.log('[PageEditor] Inserting files after page:', { + pageNumber: insertAfterPage, + pageId: insertAfterPageId, + }); + let addedFileIds: FileId[] = []; if (isFromStorage) { const stubs = files as StirlingFileStub[]; @@ -307,6 +329,10 @@ export const usePageEditorCommands = ({ insertAfterPageId, }); addedFileIds = result.map((file) => file.fileId); + console.log('[PageEditor] Files added to context:', { + addedCount: addedFileIds.length, + fileIds: addedFileIds, + }); } await new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts b/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts index e77e526c9..2d1fe0a06 100644 --- a/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts +++ b/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument'; import { PDFDocument } from '@app/types/pageEditor'; @@ -9,13 +9,29 @@ import { PDFDocument } from '@app/types/pageEditor'; export function useInitialPageDocument(): PDFDocument | null { const { document: liveDocument } = usePageDocument(); const [initialDocument, setInitialDocument] = useState(null); + const lastDocumentIdRef = useRef(null); + const liveDocumentId = liveDocument?.id ?? null; useEffect(() => { - // Only set once when we get the first non-null document - if (liveDocument && !initialDocument) { - console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages'); - setInitialDocument(liveDocument); + if (!liveDocumentId) { + lastDocumentIdRef.current = null; + setInitialDocument(null); + return; } + + if (liveDocumentId !== lastDocumentIdRef.current) { + lastDocumentIdRef.current = liveDocumentId; + setInitialDocument(null); + } + }, [liveDocumentId]); + + useEffect(() => { + if (!liveDocument || initialDocument) { + return; + } + + console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages'); + setInitialDocument(liveDocument); }, [liveDocument, initialDocument]); return initialDocument; diff --git a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts index 85ee311d6..4e3255764 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useState } from 'react'; +import { useMemo, useEffect, useRef, useState } from 'react'; import { useFileState } from '@app/contexts/FileContext'; import { usePageEditor } from '@app/contexts/PageEditorContext'; import { PDFDocument, PDFPage } from '@app/types/pageEditor'; @@ -59,22 +59,26 @@ export function usePageDocument(): PageDocumentHook { const processedFilePages = primaryStirlingFileStub?.processedFile?.pages; const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages; - const [placeholderDocument, setPlaceholderDocument] = useState(null); + const placeholderDocumentRef = useRef(null); + const [placeholderVersion, setPlaceholderVersion] = useState(0); useEffect(() => { if (!primaryFileId) { - setPlaceholderDocument(null); + placeholderDocumentRef.current = null; + setPlaceholderVersion(v => v + 1); return; } if (primaryStirlingFileStub?.processedFile) { - setPlaceholderDocument(null); + placeholderDocumentRef.current = null; + setPlaceholderVersion(v => v + 1); return; } const file = selectors.getFile(primaryFileId); if (!file) { - setPlaceholderDocument(null); + placeholderDocumentRef.current = null; + setPlaceholderVersion(v => v + 1); return; } @@ -95,16 +99,20 @@ export function usePageDocument(): PageDocumentHook { originalPageNumber: index + 1, })); - setPlaceholderDocument({ - id: `placeholder-${primaryFileId}`, - name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name, - file, - pages, - totalPages, - }); + if (!canceled) { + placeholderDocumentRef.current = { + id: `placeholder-${primaryFileId}`, + name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name, + file, + pages, + totalPages, + }; + setPlaceholderVersion(v => v + 1); + } } catch { if (!canceled) { - setPlaceholderDocument(null); + placeholderDocumentRef.current = null; + setPlaceholderVersion(v => v + 1); } } }; @@ -122,19 +130,45 @@ export function usePageDocument(): PageDocumentHook { }, [currentPages]); const mergedPdfDocument = useMemo((): PDFDocument | null => { - if (activeFileIds.length === 0) return null; + console.log('[usePageDocument] Building document:', { + activeFileIds: activeFileIds.length, + selectedActiveFileIds: selectedActiveFileIds.length, + hasPersistedDoc: !!persistedDocument, + persistedDocPages: persistedDocument?.pages.length, + persistedSig: persistedDocumentSignature?.substring(0, 50), + currentSig: currentPagesSignature.substring(0, 50), + }); - if ( + if (activeFileIds.length === 0) { + console.log('[usePageDocument] No active files, returning null'); + return null; + } + + // Check if persisted document is still valid + // Must match signature AND have the same number of source files + const persistedFileIds = persistedDocument + ? Array.from(new Set(persistedDocument.pages.map(p => p.originalFileId).filter(Boolean))) + : []; + const persistedIsValid = persistedDocument && persistedDocumentSignature && - persistedDocumentSignature === currentPagesSignature && - currentPagesSignature.length > 0 - ) { + persistedDocumentSignature === currentPagesSignature && + currentPagesSignature.length > 0 && + persistedFileIds.length === activeFileIds.length; // Ensure file count matches + + if (persistedIsValid) { + console.log('[usePageDocument] Using persisted document'); return persistedDocument; + } else if (persistedDocument) { + console.log('[usePageDocument] Persisted document invalid - rebuilding:', { + sigMatch: persistedDocumentSignature === currentPagesSignature, + persistedFiles: persistedFileIds.length, + activeFiles: activeFileIds.length, + }); } - if (!primaryStirlingFileStub?.processedFile && placeholderDocument) { - return placeholderDocument; + if (!primaryStirlingFileStub?.processedFile && placeholderDocumentRef.current) { + return placeholderDocumentRef.current; } const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; @@ -163,6 +197,10 @@ export function usePageDocument(): PageDocumentHook { activeFileIds.forEach(fileId => { const record = selectors.getStirlingFileStub(fileId); if (record?.insertAfterPageId !== undefined) { + console.log('[usePageDocument] File has insertAfterPageId:', { + fileId, + insertAfterPageId: record.insertAfterPageId, + }); if (!insertionMap.has(record.insertAfterPageId)) { insertionMap.set(record.insertAfterPageId, []); } @@ -172,6 +210,12 @@ export function usePageDocument(): PageDocumentHook { } }); + console.log('[usePageDocument] File categorization:', { + originalFiles: originalFileIds.length, + filesToInsert: insertionMap.size, + totalActive: activeFileIds.length, + }); + // Build pages by interleaving original pages with insertions let pages: PDFPage[] = []; @@ -203,13 +247,15 @@ export function usePageDocument(): PageDocumentHook { if (processedFile?.pages && processedFile.pages.length > 0) { // Use fully processed pages with thumbnails filePages = processedFile.pages.map((page, pageIndex) => ({ - id: `${fileId}-${page.pageNumber}`, + id: `${fileId}-${pageIndex + 1}`, pageNumber: startPageNumber + pageIndex, thumbnail: page.thumbnail || null, rotation: page.rotation || 0, selected: false, splitAfter: page.splitAfter || false, - originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1, + // Always use pageIndex + 1 for originalPageNumber to ensure correct numbering + // This prevents stale or incorrect page numbers from being cached + originalPageNumber: pageIndex + 1, originalFileId: fileId, isPlaceholder: false, })); @@ -226,6 +272,20 @@ export function usePageDocument(): PageDocumentHook { splitAfter: false, isPlaceholder: false, })); + } else { + // No processedFile yet - create a single loading placeholder + // This will be replaced when processing completes + filePages = [{ + id: `${fileId}-loading`, + pageNumber: startPageNumber, + originalPageNumber: 1, + originalFileId: fileId, + rotation: 0, + thumbnail: null, + selected: false, + splitAfter: false, + isPlaceholder: true, + }]; } return filePages; @@ -327,12 +387,13 @@ export function usePageDocument(): PageDocumentHook { activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, + state.files.byId, // Force recompute when any file stub changes (including processedFile updates) allFileIds, currentPagesSignature, currentPages, persistedDocument, persistedDocumentSignature, - placeholderDocument, + placeholderVersion, ]); // Large document detection for smart loading diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts index a41027a98..3a24d9089 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -8,7 +8,7 @@ import { documentManipulationService } from "@app/services/documentManipulationS import { pdfExportService } from "@app/services/pdfExportService"; import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers"; import { FileId } from "@app/types/file"; -import { PDFDocument } from "@app/types/pageEditor"; +import { PDFDocument, PDFPage } from "@app/types/pageEditor"; type FileActions = ReturnType["actions"]; type FileSelectors = ReturnType["selectors"]; @@ -16,14 +16,16 @@ type FileSelectors = ReturnType["selectors"]; interface UsePageEditorExportParams { displayDocument: PDFDocument | null; selectedPageIds: string[]; - splitPositions: Set; + splitPositions: Set; selectedFileIds: FileId[]; selectors: FileSelectors; actions: FileActions; setHasUnsavedChanges: (dirty: boolean) => void; exportLoading: boolean; setExportLoading: (loading: boolean) => void; - setSplitPositions: Dispatch>>; + setSplitPositions: Dispatch>>; + clearPersistedDocument: () => void; + updateCurrentPages: (pages: PDFPage[] | null) => void; } const removePlaceholderPages = (document: PDFDocument): PDFDocument => { @@ -67,6 +69,8 @@ export const usePageEditorExport = ({ exportLoading, setExportLoading, setSplitPositions, + clearPersistedDocument, + updateCurrentPages, }: UsePageEditorExportParams) => { const getSourceFiles = useCallback((): Map | null => { const sourceFiles = new Map(); @@ -272,6 +276,18 @@ export const usePageEditorExport = ({ // Store source file IDs before adding new files const sourceFileIds = [...selectedFileIds]; + // Clear all cached page state to prevent stale data from being merged + clearPersistedDocument(); + updateCurrentPages(null); + + // Deselect old files immediately so the view can reset before we mutate the file list + actions.setSelectedFiles([]); + + // Remove the original files before inserting the newly generated versions + if (sourceFileIds.length > 0) { + await actions.removeFiles(sourceFileIds, true); + } + const newStirlingFiles = await actions.addFiles(renamedFiles, { selectFiles: true, }); @@ -279,11 +295,6 @@ export const usePageEditorExport = ({ actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId)); } - // Remove source files from context - if (sourceFileIds.length > 0) { - await actions.removeFiles(sourceFileIds, true); - } - setHasUnsavedChanges(false); setSplitPositions(new Set()); setExportLoading(false); @@ -300,6 +311,8 @@ export const usePageEditorExport = ({ selectedFileIds, setHasUnsavedChanges, setExportLoading, + clearPersistedDocument, + updateCurrentPages, ]); return { diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts index 2c37990d1..570f0d08b 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts @@ -11,7 +11,7 @@ export interface PageEditorState { isAnimating: boolean; // Split state - splitPositions: Set; + splitPositions: Set; // Export state exportLoading: boolean; @@ -21,7 +21,7 @@ export interface PageEditorState { setSelectedPageIds: (pages: string[]) => void; setMovingPage: (pageNumber: number | null) => void; setIsAnimating: (animating: boolean) => void; - setSplitPositions: React.Dispatch>>; + setSplitPositions: React.Dispatch>>; setExportLoading: (loading: boolean) => void; // Helper functions @@ -44,19 +44,20 @@ export function usePageEditorState(): PageEditorState { const [isAnimating, setIsAnimating] = useState(false); // Split state - position-based split tracking (replaces page-based splitAfter) - const [splitPositions, setSplitPositions] = useState>(new Set()); + const [splitPositions, setSplitPositions] = useState>(new Set()); // Export state const [exportLoading, setExportLoading] = useState(false); // Helper functions const togglePage = useCallback((pageId: string) => { - setSelectedPageIds(prev => - prev.includes(pageId) + setSelectedPageIds(prev => { + const newSelection = prev.includes(pageId) ? prev.filter(id => id !== pageId) - : [...prev, pageId] - ); - }, []); + : [...prev, pageId]; + return newSelection; + }); + }, []); // Empty deps - uses updater function so always has latest state const toggleSelectAll = useCallback((allPageIds: string[]) => { if (!allPageIds.length) return; @@ -93,4 +94,4 @@ export function usePageEditorState(): PageEditorState { toggleSelectAll, animateReorder, }; -} \ No newline at end of file +} diff --git a/frontend/src/core/components/pageEditor/utils/splitPositions.ts b/frontend/src/core/components/pageEditor/utils/splitPositions.ts new file mode 100644 index 000000000..32630a110 --- /dev/null +++ b/frontend/src/core/components/pageEditor/utils/splitPositions.ts @@ -0,0 +1,36 @@ +import { PDFDocument } from '@app/types/pageEditor'; + +/** + * Build a map from page ID to its index in the provided document. + */ +export function buildPageIdIndexMap(document: PDFDocument | null): Map { + const map = new Map(); + if (!document) return map; + document.pages.forEach((page, index) => { + map.set(page.id, index); + }); + return map; +} + +/** + * Convert a set of split page IDs (the page preceding each split) into + * the current index positions inside the document. + */ +export function convertSplitPageIdsToIndexes(document: PDFDocument | null, splitPageIds: Set): Set { + const indexes = new Set(); + if (!document || !splitPageIds || splitPageIds.size === 0) { + return indexes; + } + + const totalPages = document.pages.length; + document.pages.forEach((page, index) => { + if (index >= totalPages - 1) { + return; // Cannot split after the last page. + } + if (splitPageIds.has(page.id)) { + indexes.add(index); + } + }); + + return indexes; +} diff --git a/frontend/src/core/contexts/PageEditorContext.tsx b/frontend/src/core/contexts/PageEditorContext.tsx index 2acee3ec2..60e5969d8 100644 --- a/frontend/src/core/contexts/PageEditorContext.tsx +++ b/frontend/src/core/contexts/PageEditorContext.tsx @@ -3,6 +3,7 @@ import { FileId } from '@app/types/file'; import { useFileActions, useFileState } from '@app/contexts/FileContext'; import { PDFDocument, PDFPage } from '@app/types/pageEditor'; import { MAX_PAGE_EDITOR_FILES } from '@app/components/pageEditor/fileColors'; +import { useNavigationState } from '@app/contexts/NavigationContext'; // PageEditorFile is now defined locally in consuming components // Components should derive file list directly from FileContext @@ -154,8 +155,10 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) { }, []); const clearPersistedDocument = useCallback(() => { + console.log('[PageEditorContext] Clearing persisted document'); setPersistedDocument(null); setPersistedDocumentSignature(null); + setCurrentPages(null); }, []); // Page editor's own file order (independent of FileContext) @@ -165,6 +168,42 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) { const { actions: fileActions } = useFileActions(); const { state } = useFileState(); + const navigationState = useNavigationState(); + const prevWorkbenchRef = useRef(navigationState.workbench); + useEffect(() => { + const prevWorkbench = prevWorkbenchRef.current; + const nextWorkbench = navigationState.workbench; + const isLeavingPageEditor = prevWorkbench === 'pageEditor' && nextWorkbench !== 'pageEditor'; + const isEnteringPageEditor = prevWorkbench !== 'pageEditor' && nextWorkbench === 'pageEditor'; + + if (isLeavingPageEditor) { + clearPersistedDocument(); + } + + if (isEnteringPageEditor) { + prevFileContextIdsRef.current = state.files.ids; + setReorderedPages(null); + setCurrentPages(null); // Force clear current pages when entering + setFileOrder(currentOrder => { + const validOrder = currentOrder.filter(id => state.files.ids.includes(id)); + const newIds = state.files.ids.filter(id => !validOrder.includes(id)); + if (newIds.length === 0 && validOrder.length === currentOrder.length) { + return currentOrder; + } + return [...validOrder, ...newIds]; + }); + clearPersistedDocument(); + } + + prevWorkbenchRef.current = nextWorkbench; + }, [ + navigationState.workbench, + clearPersistedDocument, + state.files.ids, + setFileOrder, + setReorderedPages, + ]); + const fileContextSignature = useMemo(() => { return state.files.ids .map(id => `${id}:${state.files.byId[id]?.versionNumber ?? 0}`) @@ -172,12 +211,41 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) { }, [state.files.ids, state.files.byId]); const prevFileContextSignature = useRef(null); - useEffect(() => { - if (prevFileContextSignature.current !== fileContextSignature) { - prevFileContextSignature.current = fileContextSignature; - clearPersistedDocument(); + const haveFileIdSetsChanged = (prevIds: FileId[], currentIds: FileId[]) => { + if (prevIds.length !== currentIds.length) { + return true; } - }, [fileContextSignature, clearPersistedDocument]); + const prevSet = new Set(prevIds); + for (const id of currentIds) { + if (!prevSet.has(id)) { + return true; + } + } + return false; + }; + useEffect(() => { + const currentFileIds = state.files.ids; + const prevFileIds = prevFileContextIdsRef.current; + const idsChanged = haveFileIdSetsChanged(prevFileIds, currentFileIds); + + if (!idsChanged && prevFileContextSignature.current === fileContextSignature) { + return; + } + + const previousSignature = prevFileContextSignature.current; + prevFileContextSignature.current = fileContextSignature; + + if (!idsChanged) { + // Signature changed due to metadata/version updates but file set is unchanged. + return; + } + + console.log('[PageEditorContext] File signature changed (IDs/versions changed), clearing persisted document:', { + prev: previousSignature?.substring(0, 50), + current: fileContextSignature.substring(0, 50), + }); + clearPersistedDocument(); + }, [fileContextSignature, clearPersistedDocument, state.files.ids]); // Keep a ref to always read latest state in stable callbacks const stateRef = useRef(state); @@ -194,14 +262,21 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) { const prevFileIds = prevFileContextIdsRef.current; // Only react to FileContext changes, not our own fileOrder changes - const fileContextChanged = - currentFileIds.length !== prevFileIds.length || - !currentFileIds.every((id, idx) => id === prevFileIds[idx]); + const fileContextChanged = haveFileIdSetsChanged(prevFileIds, currentFileIds); if (!fileContextChanged) { return; } + console.log('[PageEditorContext] FileContext files changed:', { + prevCount: prevFileIds.length, + currentCount: currentFileIds.length, + added: currentFileIds.filter(id => !prevFileIds.includes(id)).length, + removed: prevFileIds.filter(id => !currentFileIds.includes(id)).length, + }); + + clearPersistedDocument(); + prevFileContextIdsRef.current = currentFileIds; // Collect new file IDs outside the setState callback so we can clear them after diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index 574e3a692..adf05f9e0 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -31,14 +31,16 @@ const scheduleMetadataHydration = (task: () => Promise): void => { }; const drainHydrationQueue = (): void => { - if (activeHydrations >= HYDRATION_CONCURRENCY) return; + if (activeHydrations >= HYDRATION_CONCURRENCY) { + return; + } const nextTask = hydrationQueue.shift(); if (!nextTask) return; activeHydrations++; nextTask() - .catch(() => { - // Silently handle hydration failures + .catch((error) => { + console.error('[Hydration] Task failed with error:', error); }) .finally(() => { activeHydrations--; @@ -341,8 +343,8 @@ export async function addFiles( try { const { generateThumbnailForFile } = await import('@app/utils/thumbnailUtils'); thumbnail = await generateThumbnailForFile(targetFile); - } catch { - // Silently handle thumbnail generation failures + } catch (error) { + console.warn(`[addFiles] Thumbnail generation failed for ${fileId}:`, error); } } @@ -640,7 +642,6 @@ export async function addStirlingFileStubs( scheduleMetadataHydration(async () => { const stirlingFile = await fileStorage.getStirlingFile(fileId); if (!stirlingFile) { - console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${fileId})`); return; } @@ -657,6 +658,7 @@ export async function addStirlingFileStubs( if (needsProcessing) { // Regenerate metadata const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile); + if (processedFileMetadata) { const updates: Partial = { processedFile: processedFileMetadata diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index c22a9624d..2416f4fe5 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -10,7 +10,7 @@ import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useLogoPath } from "@app/hooks/useLogoPath"; import { useLogoAssets } from '@app/hooks/useLogoAssets'; import { useFileContext } from "@app/contexts/file/fileHooks"; -import { useNavigationActions } from "@app/contexts/NavigationContext"; +import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import AppsIcon from '@mui/icons-material/AppsRounded'; @@ -54,6 +54,7 @@ export default function HomePage() { const [configModalOpen, setConfigModalOpen] = useState(false); const { activeFiles } = useFileContext(); + const navigationState = useNavigationState(); const { actions } = useNavigationActions(); const { setActiveFileIndex } = useViewer(); const prevFileCountRef = useRef(activeFiles.length); @@ -64,7 +65,11 @@ export default function HomePage() { const prevCount = prevFileCountRef.current; const currentCount = activeFiles.length; - if (prevCount === 0 && currentCount === 1) { + if ( + navigationState.workbench !== 'fileEditor' && + prevCount === 0 && + currentCount === 1 + ) { // PDF Text Editor handles its own empty state with a dropzone if (selectedToolKey !== 'pdfTextEditor') { actions.setWorkbench('viewer'); @@ -73,7 +78,13 @@ export default function HomePage() { } prevFileCountRef.current = currentCount; - }, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey]); + }, [ + activeFiles.length, + actions, + setActiveFileIndex, + selectedToolKey, + navigationState.workbench, + ]); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); const brandIconSrc = useLogoPath(); diff --git a/frontend/src/core/services/documentManipulationService.ts b/frontend/src/core/services/documentManipulationService.ts index edcb2a535..53758f35d 100644 --- a/frontend/src/core/services/documentManipulationService.ts +++ b/frontend/src/core/services/documentManipulationService.ts @@ -1,4 +1,5 @@ import { PDFDocument, PDFPage } from '@app/types/pageEditor'; +import { convertSplitPageIdsToIndexes } from '@app/components/pageEditor/utils/splitPositions'; /** * Service for applying DOM changes to PDF document state @@ -9,7 +10,7 @@ export class DocumentManipulationService { * Apply all DOM changes (rotations, splits, reordering) to document state * Returns single document or multiple documents if splits are present */ - applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set): PDFDocument | PDFDocument[] { + applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set): PDFDocument | PDFDocument[] { // Use current display order (from React state) if provided, otherwise use original order const baseDocument = currentDisplayOrder || pdfDocument; @@ -17,10 +18,14 @@ export class DocumentManipulationService { let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page)); // Convert position-based splits to page-based splits for export - if (splitPositions && splitPositions.size > 0) { + const resolvedSplitIndexes = splitPositions && splitPositions.size > 0 + ? convertSplitPageIdsToIndexes(baseDocument, splitPositions) + : new Set(); + + if (resolvedSplitIndexes.size > 0) { updatedPages = updatedPages.map((page, index) => ({ ...page, - splitAfter: splitPositions.has(index) + splitAfter: resolvedSplitIndexes.has(index) })); } @@ -31,7 +36,7 @@ export class DocumentManipulationService { }; // Check for splits and return multiple documents if needed - if (splitPositions && splitPositions.size > 0) { + if (resolvedSplitIndexes.size > 0) { return this.createSplitDocuments(finalDocument); } @@ -162,4 +167,4 @@ export class DocumentManipulationService { } // Export singleton instance -export const documentManipulationService = new DocumentManipulationService(); \ No newline at end of file +export const documentManipulationService = new DocumentManipulationService(); diff --git a/frontend/src/core/types/pageEditor.ts b/frontend/src/core/types/pageEditor.ts index b50b6613c..46fcd06e4 100644 --- a/frontend/src/core/types/pageEditor.ts +++ b/frontend/src/core/types/pageEditor.ts @@ -76,6 +76,6 @@ export interface PageEditorFunctions { selectionMode: boolean; selectedPageIds: string[]; displayDocument?: PDFDocument; - splitPositions: Set; + splitPositions: Set; totalPages: number; } diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index eda782550..89d793d63 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -390,13 +390,8 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b return { thumbnail, pageCount: 0 }; } - // Skip very large files - if (file.size >= 100 * 1024 * 1024) { - const thumbnail = generatePlaceholderThumbnail(file); - return { thumbnail, pageCount: 1 }; - } - const scale = calculateScaleFromFileSize(file.size); + const isVeryLarge = file.size >= 100 * 1024 * 1024; // 100MB threshold try { const arrayBuffer = await file.arrayBuffer(); @@ -430,6 +425,18 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b await page.render({ canvasContext: context, viewport, canvas }).promise; const thumbnail = canvas.toDataURL(); + // For very large files, skip reading rotation/dimensions for all pages (just use first page data) + if (isVeryLarge) { + const rotation = page.rotate || 0; + pdfWorkerManager.destroyDocument(pdf); + return { + thumbnail, + pageCount, + pageRotations: [rotation], + pageDimensions: [pageDimensions[0]] + }; + } + // Read rotation for all pages const pageRotations: number[] = []; for (let i = 1; i <= pageCount; i++) {