Files
Stirling-PDF/frontend/src/core/contexts/PageEditorContext.tsx
T

474 lines
16 KiB
TypeScript

import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo, useRef, useEffect } from 'react';
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
/**
* Computes file order based on the position of each file's first page
* @param pages - Current page order
* @returns Array of FileIds in order based on first page positions
*/
function computeFileOrderFromPages(pages: PDFPage[]): FileId[] {
// Find the first page for each file
const fileFirstPagePositions = new Map<FileId, number>();
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 fileOrder = Array.from(fileFirstPagePositions.entries())
.sort((a, b) => a[1] - b[1])
.map(entry => entry[0]);
return fileOrder;
}
/**
* Reorders pages based on file reordering while preserving interlacing and manual page order
* @param currentPages - Current page order (may include manual reordering and interlacing)
* @param fromIndex - Source file index in the file order
* @param toIndex - Target file index in the file order
* @param orderedFileIds - File IDs in their current order
* @returns Reordered pages with updated page numbers
*/
function reorderPagesForFileMove(
currentPages: PDFPage[],
fromIndex: number,
toIndex: number,
orderedFileIds: FileId[]
): PDFPage[] {
// Get the file ID being moved
const movedFileId = orderedFileIds[fromIndex];
const targetFileId = orderedFileIds[toIndex];
// Extract pages belonging to the moved file (maintaining their relative order)
const movedFilePages: PDFPage[] = [];
const remainingPages: PDFPage[] = [];
currentPages.forEach(page => {
if (page.originalFileId === movedFileId) {
movedFilePages.push(page);
} else {
remainingPages.push(page);
}
});
// Find the insertion point based on the target file
let insertionIndex = 0;
if (fromIndex < toIndex) {
// Moving down: insert AFTER the last page of ANY file that should come before us
// We need to find the last page belonging to any file at index <= toIndex in orderedFileIds
const filesBeforeUs = new Set(orderedFileIds.slice(0, toIndex + 1));
for (let i = remainingPages.length - 1; i >= 0; i--) {
const pageFileId = remainingPages[i].originalFileId;
if (pageFileId && filesBeforeUs.has(pageFileId)) {
insertionIndex = i + 1;
break;
}
}
} else {
// Moving up: insert BEFORE the first page of target file
for (let i = 0; i < remainingPages.length; i++) {
if (remainingPages[i].originalFileId === targetFileId) {
insertionIndex = i;
break;
}
}
}
// Insert moved pages at the calculated position
const reorderedPages = [
...remainingPages.slice(0, insertionIndex),
...movedFilePages,
...remainingPages.slice(insertionIndex)
];
// Renumber all pages sequentially (clone to avoid mutation)
return reorderedPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
}
interface PageEditorContextValue {
// Current page order (updated by PageEditor, used for file reordering)
currentPages: PDFPage[] | null;
updateCurrentPages: (pages: PDFPage[] | null) => void;
// Reordered pages (when file reordering happens)
reorderedPages: PDFPage[] | null;
clearReorderedPages: () => void;
// 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 (calls FileContext actions)
toggleFileSelection: (fileId: FileId) => void;
// Select/deselect all files (calls FileContext actions)
selectAll: () => void;
deselectAll: () => void;
// 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;
persistedDocument: PDFDocument | null;
persistedDocumentSignature: string | null;
savePersistedDocument: (document: PDFDocument, signature: string) => void;
clearPersistedDocument: () => void;
}
const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined);
interface PageEditorProviderProps {
children: ReactNode;
}
export function PageEditorProvider({ children }: PageEditorProviderProps) {
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
const [reorderedPages, setReorderedPages] = useState<PDFPage[] | null>(null);
const [persistedDocument, setPersistedDocument] = useState<PDFDocument | null>(null);
const [persistedDocumentSignature, setPersistedDocumentSignature] = useState<string | null>(null);
const savePersistedDocument = useCallback((document: PDFDocument, signature: string) => {
setPersistedDocument(document);
setPersistedDocumentSignature(signature);
}, []);
const clearPersistedDocument = useCallback(() => {
console.log('[PageEditorContext] Clearing persisted document');
setPersistedDocument(null);
setPersistedDocumentSignature(null);
setCurrentPages(null);
}, []);
// Page editor's own file order (independent of FileContext)
const [fileOrder, setFileOrder] = useState<FileId[]>([]);
// Read from FileContext (for file metadata only, not order)
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}`)
.join(',');
}, [state.files.ids, state.files.byId]);
const prevFileContextSignature = useRef<string | null>(null);
const haveFileIdSetsChanged = (prevIds: FileId[], currentIds: FileId[]) => {
if (prevIds.length !== currentIds.length) {
return true;
}
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);
useEffect(() => {
stateRef.current = state;
}, [state]);
// Track the previous FileContext order to detect actual changes
const prevFileContextIdsRef = useRef<FileId[]>([]);
// Initialize fileOrder from FileContext when files change (add/remove only)
useEffect(() => {
const currentFileIds = state.files.ids;
const prevFileIds = prevFileContextIdsRef.current;
// Only react to FileContext changes, not our own fileOrder changes
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
let newFileIdsToProcess: FileId[] = [];
// Use functional setState to read latest fileOrder without depending on it
setFileOrder(currentOrder => {
// Identify new files
const newFileIds = currentFileIds.filter(id => !currentOrder.includes(id));
newFileIdsToProcess = newFileIds; // Store for cleanup
// Remove deleted files
const validFileOrder = currentOrder.filter(id => currentFileIds.includes(id));
if (newFileIds.length === 0 && validFileOrder.length === currentOrder.length) {
return currentOrder; // No changes needed
}
// Always append new files to end
// If files have insertAfterPageId, page-level insertion is handled by usePageDocument
return [...validFileOrder, ...newFileIds];
});
// Clear insertAfterPageId after a delay to allow usePageDocument to consume it first
setTimeout(() => {
newFileIdsToProcess.forEach(fileId => {
const stub = state.files.byId[fileId];
if (stub?.insertAfterPageId) {
fileActions.updateStirlingFileStub(fileId, { insertAfterPageId: undefined });
}
});
}, 100);
}, [state.files.ids, state.files.byId, fileActions]);
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
setCurrentPages(pages);
}, []);
const clearReorderedPages = useCallback(() => {
setReorderedPages(null);
}, []);
const setFileSelection = useCallback((fileId: FileId, selected: boolean) => {
const currentSelection = stateRef.current.ui.selectedFileIds;
const isAlreadySelected = currentSelection.includes(fileId);
// 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;
}
// 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<FileId>();
currentPages.forEach(page => {
const fileId = page.originalFileId;
if (fileId && !filesSeen.has(fileId)) {
filesSeen.add(fileId);
currentFileOrder.push(fileId);
}
});
// Get the target file ID from the NEW order (after the move)
// When moving down: we want to position after the file at toIndex-1 (file just before insertion)
// When moving up: we want to position before the file at toIndex+1 (file just after insertion)
const targetFileId = fromIndex < toIndex
? newOrder[toIndex - 1] // Moving down: target is the file just before where we inserted
: newOrder[toIndex + 1]; // Moving up: target is the file just after where we inserted
// 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 local page editor file order (not FileContext)
setFileOrder(newFileOrder);
}
}, []);
const value: PageEditorContextValue = useMemo(() => ({
currentPages,
updateCurrentPages,
reorderedPages,
clearReorderedPages,
fileOrder,
setFileOrder,
setFileSelection,
toggleFileSelection,
selectAll,
deselectAll,
reorderFiles,
updateFileOrderFromPages,
persistedDocument,
persistedDocumentSignature,
savePersistedDocument,
clearPersistedDocument,
}), [
currentPages,
updateCurrentPages,
reorderedPages,
clearReorderedPages,
fileOrder,
setFileSelection,
toggleFileSelection,
selectAll,
deselectAll,
reorderFiles,
updateFileOrderFromPages,
persistedDocument,
persistedDocumentSignature,
savePersistedDocument,
clearPersistedDocument,
]);
return (
<PageEditorContext.Provider value={value}>
{children}
</PageEditorContext.Provider>
);
}
export function usePageEditor() {
const context = useContext(PageEditorContext);
if (!context) {
throw new Error('usePageEditor must be used within PageEditorProvider');
}
return context;
}