Refactor pageeditor.tsx

This commit is contained in:
Reece 2025-11-10 18:02:26 +00:00
parent 662a34637e
commit e8230ee06f
6 changed files with 1126 additions and 749 deletions

View File

@ -3,11 +3,7 @@ import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { usePageEditor } from "@app/contexts/PageEditorContext";
import { PDFDocument, PDFPage, PageEditorFunctions } from "@app/types/pageEditor";
import { StirlingFileStub } from "@app/types/fileContext";
import { pdfExportService } from "@app/services/pdfExportService";
import { documentManipulationService } from "@app/services/documentManipulationService";
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
import { PageEditorFunctions } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components
import '@app/components/pageEditor/PageEditor.module.css';
import PageThumbnail from '@app/components/pageEditor/PageThumbnail';
@ -15,23 +11,18 @@ import DragDropGrid from '@app/components/pageEditor/DragDropGrid';
import SkeletonLoader from '@app/components/shared/SkeletonLoader';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { FileId } from "@app/types/file";
import {
DeletePagesCommand,
ReorderPagesCommand,
SplitCommand,
BulkRotateCommand,
PageBreakCommand,
UndoManager
} from '@app/components/pageEditor/commands/pageCommands';
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
import { useInitialPageDocument } from '@app/components/pageEditor/hooks/useInitialPageDocument';
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useEditedDocumentState } from "@app/components/pageEditor/hooks/useEditedDocumentState";
import { useUndoManagerState } from "@app/components/pageEditor/hooks/useUndoManagerState";
import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePageSelectionManager";
import { usePageEditorCommands } from "@app/components/pageEditor/hooks/usePageEditorCommands";
import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport";
export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
@ -85,8 +76,6 @@ const PageEditor = ({
const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = useRef(new Map<FileId, any>());
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
const gridItemRefsRef = useRef<React.MutableRefObject<Map<string, HTMLDivElement>> | null>(null);
const pageEditorFiles = useMemo(() => {
@ -148,158 +137,17 @@ const PageEditor = ({
// UI state
const globalProcessing = state.ui.isProcessing;
// Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
// DOM-first undo manager (replaces the old React state undo system)
const undoManagerRef = useRef(new UndoManager());
// Document state management
// Get initial document ONCE - useInitialPageDocument captures first value only
const initialDocument = useInitialPageDocument();
// Also get live mergedPdfDocument for delta sync (source of truth for page existence)
const { document: mergedPdfDocument } = usePageDocument();
// Initialize editedDocument from initial document
useEffect(() => {
if (!initialDocument || editedDocument) return;
console.log('📄 Initializing editedDocument from initial document:', initialDocument.pages.length, 'pages');
// Clone to avoid mutation
setEditedDocument({
...initialDocument,
pages: initialDocument.pages.map(p => ({ ...p }))
});
}, [initialDocument, editedDocument]);
// Apply file reordering from PageEditorContext
useEffect(() => {
if (reorderedPages && editedDocument) {
setEditedDocument({
...editedDocument,
pages: reorderedPages
});
clearReorderedPages();
}
}, [reorderedPages, editedDocument, clearReorderedPages]);
useEffect(() => {
if (!editedDocument) return;
const positionCache = pagePositionCacheRef.current;
const neighborCache = pageNeighborCacheRef.current;
const pages = editedDocument.pages;
pages.forEach((page, index) => {
positionCache.set(page.id, index);
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
});
}, [editedDocument]);
// Live delta sync: reflect external add/remove without touching existing order
useEffect(() => {
if (!mergedPdfDocument || !editedDocument) return;
const sourcePages = mergedPdfDocument.pages;
const sourceIds = new Set(sourcePages.map(p => p.id));
// Group new pages by file (preserve within-file order from source)
const prevIds = new Set(editedDocument.pages.map(p => p.id));
const newPages: PDFPage[] = [];
for (const p of sourcePages) {
if (!prevIds.has(p.id)) {
newPages.push(p);
}
}
// Fast check: changes exist?
const hasAdditions = newPages.length > 0;
let hasRemovals = false;
for (const p of editedDocument.pages) {
if (!sourceIds.has(p.id)) {
hasRemovals = true;
break;
}
}
if (!hasAdditions && !hasRemovals) return;
setEditedDocument(prev => {
if (!prev) return prev;
let pages = [...prev.pages];
// Capture placeholder positions before they are removed so we can restore files without disrupting current order
const placeholderPositions = new Map<FileId, number>();
pages.forEach((page, index) => {
if (page.isPlaceholder && page.originalFileId) {
placeholderPositions.set(page.originalFileId, index);
}
});
// Track next insertion index per file when replacing placeholders
const nextInsertIndexByFile = new Map(placeholderPositions);
// Remove pages that no longer exist in source
if (hasRemovals) {
pages = pages.filter(p => sourceIds.has(p.id));
}
// Insert new pages while preserving current interleaving
if (hasAdditions) {
const mergedIndexMap = new Map<string, number>();
sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index));
const additions = newPages
.map(page => ({
page,
cachedIndex: pagePositionCacheRef.current.get(page.id),
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
neighborId: pageNeighborCacheRef.current.get(page.id)
}))
.sort((a, b) => {
const aIndex = a.cachedIndex ?? a.mergedIndex;
const bIndex = b.cachedIndex ?? b.mergedIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
return a.mergedIndex - b.mergedIndex;
});
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
if (pages.some(existing => existing.id === page.id)) {
return;
}
let insertIndex: number;
const originalFileId = page.originalFileId;
const placeholderIndex = originalFileId ? nextInsertIndexByFile.get(originalFileId) : undefined;
if (originalFileId && placeholderIndex !== undefined) {
insertIndex = Math.min(placeholderIndex, pages.length);
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
} else if (neighborId === null) {
insertIndex = 0;
} else if (neighborId) {
const neighborIndex = pages.findIndex(p => p.id === neighborId);
if (neighborIndex !== -1) {
insertIndex = neighborIndex + 1;
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
const clonedPage = { ...page };
pages.splice(insertIndex, 0, clonedPage);
});
}
// Renumber without reordering
pages = pages.map((p, i) => ({ ...p, pageNumber: i + 1 }));
return { ...prev, pages };
});
}, [fileOrder.join(','), mergedPdfDocument && mergedPdfDocument.pages.map(p => p.id).join(',')]);
const { editedDocument, setEditedDocument, displayDocument } = useEditedDocumentState({
initialDocument,
mergedPdfDocument,
reorderedPages,
clearReorderedPages,
fileOrder,
updateCurrentPages,
});
// UI state management
const {
@ -308,600 +156,86 @@ const PageEditor = ({
togglePage, toggleSelectAll, animateReorder
} = usePageEditorState();
const [csvInput, setCsvInput] = useState<string>('');
useEffect(() => {
setCsvInput('');
}, [activeFilesSignature]);
const {
csvInput,
setCsvInput,
totalPages,
getPageNumbersFromIds,
getPageIdsFromNumbers,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
updatePagesFromCSV,
} = usePageSelectionManager({
displayDocument,
selectedPageIds,
setSelectedPageIds,
setSelectionMode,
toggleSelectAll,
activeFilesSignature,
});
// Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null);
// Undo/Redo state
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const {
canUndo,
canRedo,
executeCommandWithTracking,
handleUndo,
handleRedo,
clearUndoHistory,
} = useUndoManagerState({ setHasUnsavedChanges });
// Update undo/redo state
const updateUndoRedoState = useCallback(() => {
const undoManager = undoManagerRef.current;
setCanUndo(undoManager.canUndo());
setCanRedo(undoManager.canRedo());
const {
createRotateCommand,
createDeleteCommand,
createSplitCommand,
executeCommand,
handleRotate,
handleDelete,
handleDeletePage,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleInsertFiles,
handleReorderPages,
closePdf,
} = usePageEditorCommands({
displayDocument,
editedDocument,
setEditedDocument,
splitPositions,
setSplitPositions,
selectedPageIds,
setSelectedPageIds,
getPageNumbersFromIds,
getPageIdsFromNumbers,
executeCommandWithTracking,
updateFileOrderFromPages,
actions,
selectors,
setSelectionMode,
clearUndoHistory,
});
if (!undoManager.hasHistory()) {
setHasUnsavedChanges(false);
}
}, [setHasUnsavedChanges]);
// Set up undo manager callback
useEffect(() => {
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
// Initialize state
updateUndoRedoState();
}, [updateUndoRedoState]);
// Wrapper for executeCommand to track unsaved changes
const executeCommandWithTracking = useCallback((command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
}, [setHasUnsavedChanges]);
// Interface functions for parent component
const displayDocument = editedDocument || initialDocument;
// Feed current pages to PageEditorContext so file reordering can compute page-level changes
useEffect(() => {
updateCurrentPages(displayDocument?.pages ?? null);
}, [displayDocument, updateCurrentPages]);
const { onExportSelected, onExportAll, applyChanges } = usePageEditorExport({
displayDocument,
selectedPageIds,
splitPositions,
selectedFileIds,
selectors,
actions,
setHasUnsavedChanges,
exportLoading,
setExportLoading,
});
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
const totalPages = displayDocument?.pages.length || 0;
const selectedPageCount = selectedPageIds.length;
const activeFileIds = selectedFileIds;
// Utility functions to convert between page IDs and page numbers
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
if (!displayDocument) return [];
return pageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0);
}, [displayDocument]);
const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => {
if (!displayDocument) return [];
return pageNumbers.map(num => {
const page = displayDocument.pages.find(p => p.pageNumber === num);
return page?.id || '';
}).filter(id => id !== '');
}, [displayDocument]);
// Select all pages by default when document initially loads
const hasInitializedSelection = useRef(false);
useEffect(() => {
if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) {
const allPageIds = displayDocument.pages.map(p => p.id);
setSelectedPageIds(allPageIds);
setSelectionMode(true);
hasInitializedSelection.current = true;
}
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
// Automatically include newly added pages in the current selection
const previousPageIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!displayDocument || displayDocument.pages.length === 0) {
previousPageIdsRef.current = new Set();
return;
}
const currentIds = new Set(displayDocument.pages.map(page => page.id));
const newlyAddedPageIds: string[] = [];
currentIds.forEach(id => {
if (!previousPageIdsRef.current.has(id)) {
newlyAddedPageIds.push(id);
}
});
if (newlyAddedPageIds.length > 0) {
const next = new Set(selectedPageIds);
newlyAddedPageIds.forEach(id => next.add(id));
setSelectedPageIds(Array.from(next));
}
previousPageIdsRef.current = currentIds;
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
// DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
}, [executeCommandWithTracking]);
// Command factory functions for PageThumbnail
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
}
}), [executeCommandWithTracking]);
const createDeleteCommand = useCallback((pageIds: string[]) => ({
execute: () => {
if (!displayDocument) return;
const pagesToDelete = pageIds.map(pageId => {
const page = displayDocument.pages.find(p => p.id === pageId);
return page?.pageNumber || 0;
}).filter(num => num > 0);
if (pagesToDelete.length > 0) {
const deleteCommand = new DeletePagesCommand(
pagesToDelete,
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
executeCommandWithTracking(deleteCommand);
}
}
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const createSplitCommand = useCallback((position: number) => ({
execute: () => {
const splitCommand = new SplitCommand(
position,
() => splitPositions,
setSplitPositions
);
executeCommandWithTracking(splitCommand);
}
}), [splitPositions, executeCommandWithTracking]);
// Command executor for PageThumbnail
const executeCommand = useCallback((command: any) => {
if (command && typeof command.execute === 'function') {
command.execute();
}
}, []);
const handleUndo = useCallback(() => {
undoManagerRef.current.undo();
}, []);
const handleRedo = useCallback(() => {
undoManagerRef.current.redo();
}, []);
const handleRotate = useCallback((direction: 'left' | 'right') => {
if (!displayDocument || selectedPageIds.length === 0) return;
const rotation = direction === 'left' ? -90 : 90;
handleRotatePages(selectedPageIds, rotation);
}, [displayDocument, selectedPageIds, handleRotatePages]);
const handleDelete = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers for the command
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const deleteCommand = new DeletePagesCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => selectedPageNumbers,
closePdf
);
executeCommandWithTracking(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
const handleDeletePage = useCallback((pageNumber: number) => {
if (!displayDocument) return;
const deleteCommand = new DeletePagesCommand(
[pageNumber],
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
executeCommandWithTracking(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers, then to split positions (0-based indices)
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) {
// Only allow splits before the last page
selectedPositions.push(pageIndex);
}
});
if (selectedPositions.length === 0) return;
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
// Remove splits from all selected positions
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
} else {
// Add splits to all selected positions
selectedPositions.forEach(pos => newSplitPositions.add(pos));
}
// Create a custom command that sets the final state directly
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
// Alias for consistency - handleSplitAll is the same as handleSplit (both have smart toggle logic)
const handleSplitAll = handleSplit;
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers for the command
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
// Alias for consistency - handlePageBreakAll is the same as handlePageBreak
const handlePageBreakAll = handlePageBreak;
const handleInsertFiles = useCallback(async (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean
) => {
if (!editedDocument || files.length === 0) return;
try {
const targetPage = editedDocument.pages.find(p => p.pageNumber === insertAfterPage);
if (!targetPage) return;
console.log('📄 handleInsertFiles: Inserting files after page', insertAfterPage, 'targetPage:', targetPage.id);
// Add files to FileContext for metadata tracking and preserve insertion point
const insertAfterPageId = targetPage.id;
let addedFileIds: FileId[] = [];
if (isFromStorage) {
const stubs = files as StirlingFileStub[];
const result = await actions.addStirlingFileStubs(stubs, {
selectFiles: true,
insertAfterPageId
});
addedFileIds = result.map(f => f.fileId);
console.log('📄 handleInsertFiles: Added stubs, IDs:', addedFileIds);
} else {
const result = await actions.addFiles(files as File[], {
selectFiles: true,
insertAfterPageId
});
addedFileIds = result.map(f => f.fileId);
console.log('📄 handleInsertFiles: Added files, IDs:', addedFileIds);
}
// Wait a moment for files to be processed
await new Promise(resolve => setTimeout(resolve, 100));
// Extract pages from newly added files and insert them into editedDocument
const newPages: PDFPage[] = [];
for (const fileId of addedFileIds) {
const stub = selectors.getStirlingFileStub(fileId);
console.log('📄 handleInsertFiles: File', fileId, 'stub:', stub?.name, 'processedFile:', stub?.processedFile?.totalPages, 'pages:', stub?.processedFile?.pages?.length);
if (stub?.processedFile?.pages) {
// Clone pages and ensure proper PDFPage structure
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
...page,
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
pageNumber: page.pageNumber ?? idx + 1,
originalFileId: fileId,
originalPageNumber: page.originalPageNumber ?? page.pageNumber ?? idx + 1,
rotation: page.rotation ?? 0,
thumbnail: page.thumbnail ?? null,
selected: false,
splitAfter: page.splitAfter ?? false,
}));
newPages.push(...clonedPages);
}
}
console.log('📄 handleInsertFiles: Collected', newPages.length, 'new pages');
if (newPages.length > 0) {
// Find insertion index in editedDocument
const targetIndex = editedDocument.pages.findIndex(p => p.id === targetPage.id);
console.log('📄 handleInsertFiles: Target index in editedDocument:', targetIndex);
if (targetIndex >= 0) {
// Clone pages and insert
const updatedPages = [...editedDocument.pages];
updatedPages.splice(targetIndex + 1, 0, ...newPages);
// Renumber all pages
updatedPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
console.log('📄 handleInsertFiles: Updated pages:', updatedPages.map(p => `${p.id}(${p.pageNumber})`));
setEditedDocument({
...editedDocument,
pages: updatedPages
});
// Keep PageEditor file order in sync with newly inserted pages
updateFileOrderFromPages(updatedPages);
}
}
} catch (error) {
console.error('Failed to insert files:', error);
}
}, [editedDocument, actions, selectors, updateFileOrderFromPages]);
const handleSelectAll = useCallback(() => {
if (!displayDocument) return;
const allPageIds = displayDocument.pages.map(p => p.id);
toggleSelectAll(allPageIds);
}, [displayDocument, toggleSelectAll]);
const handleDeselectAll = useCallback(() => {
setSelectedPageIds([]);
}, [setSelectedPageIds]);
const handleSetSelectedPages = useCallback((pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
}, [getPageIdsFromNumbers, setSelectedPageIds]);
const updatePagesFromCSV = useCallback((override?: string) => {
if (totalPages === 0) return;
const normalized = parseSelection(override ?? csvInput, totalPages);
handleSetSelectedPages(normalized);
}, [csvInput, totalPages, handleSetSelectedPages]);
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => {
if (!displayDocument) return;
// Convert selectedPageIds to page numbers for the reorder command
const selectedPages = selectedPageIds ? getPageNumbersFromIds(selectedPageIds) : undefined;
const reorderCommand = new ReorderPagesCommand(
sourcePageNumber,
targetIndex,
selectedPages,
() => displayDocument,
setEditedDocument,
(newPages) => updateFileOrderFromPages(newPages) // Sync file order when pages are reordered
);
executeCommandWithTracking(reorderCommand);
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking, updateFileOrderFromPages]);
// Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
const sourceFiles = new Map<FileId, File>();
// Always include selected files
selectedFileIds.forEach(fileId => {
const file = selectors.getFile(fileId);
if (file) {
sourceFiles.set(fileId, file);
}
});
// Use multi-file export if we have multiple original files
const hasInsertedFiles = false;
const hasMultipleOriginalFiles = selectedFileIds.length > 1;
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
return null; // Use single-file export method
}
return sourceFiles.size > 0 ? sourceFiles : null;
}, [selectedFileIds, selectors]);
// Helper function to generate proper filename for exports
const getExportFilename = useCallback((): string => {
if (selectedFileIds.length <= 1) {
// Single file - use original name
return displayDocument?.name || 'document.pdf';
}
// Multiple files - use first file name with " (merged)" suffix
const firstFile = selectors.getFile(selectedFileIds[0]);
if (firstFile) {
const baseName = firstFile.name.replace(/\.pdf$/i, '');
return `${baseName} (merged).pdf`;
}
return 'merged-document.pdf';
}, [selectedFileIds, selectors, displayDocument]);
const onExportSelected = useCallback(async () => {
if (!displayDocument || selectedPageIds.length === 0) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
displayDocument, // Original order (editedDocument is our working doc now)
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// For selected pages export, we work with the first document (or single document)
const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
// Step 2: Use the already selected page IDs
// Filter to only include IDs that exist in the document with DOM state
const validSelectedPageIds = selectedPageIds.filter(pageId =>
documentWithDOMState.pages.some(p => p.id === pageId)
);
// Step 3: Export with pdfExportService
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
documentWithDOMState,
sourceFiles,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
)
: await pdfExportService.exportPDF(
documentWithDOMState,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
);
// Step 4: Download the result
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, selectedPageIds, initialDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 3: Download
if (files.length > 1) {
// Multiple files - create ZIP
const JSZip = await import('jszip');
const zip = new JSZip.default();
files.forEach((file) => {
zip.file(file.name, file);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const exportFilename = getExportFilename();
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
// Single file - download directly
const file = files[0];
pdfExportService.downloadFile(file, file.name);
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, initialDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 3: Add merged output as new files while keeping originals
const newStirlingFiles = await actions.addFiles(files, { selectFiles: true });
if (newStirlingFiles.length > 0) {
actions.setSelectedFiles(newStirlingFiles.map(file => file.fileId));
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Apply changes failed:', error);
setExportLoading(false);
}
}, [displayDocument, initialDocument, splitPositions, getSourceFiles, getExportFilename, actions, setHasUnsavedChanges]);
const closePdf = useCallback(() => {
actions.clearAllFiles();
undoManagerRef.current.clear();
setSelectedPageIds([]);
setSelectionMode(false);
}, [actions]);
usePageEditorRightRailButtons({
totalPages,
selectedPageCount,
@ -1185,3 +519,6 @@ const PageEditor = ({
};
export default PageEditor;

View File

@ -0,0 +1,184 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { FileId } from "@app/types/file";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
interface UseEditedDocumentStateParams {
initialDocument: PDFDocument | null;
mergedPdfDocument: PDFDocument | null;
reorderedPages: PDFPage[] | null;
clearReorderedPages: () => void;
fileOrder: FileId[];
updateCurrentPages: (pages: PDFPage[] | null) => void;
}
export const useEditedDocumentState = ({
initialDocument,
mergedPdfDocument,
reorderedPages,
clearReorderedPages,
fileOrder,
updateCurrentPages,
}: UseEditedDocumentStateParams) => {
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
// Clone the initial document once so we can safely mutate working state
useEffect(() => {
if (!initialDocument || editedDocument) return;
setEditedDocument({
...initialDocument,
pages: initialDocument.pages.map((page) => ({ ...page })),
});
}, [initialDocument, editedDocument]);
// Apply reorders triggered elsewhere in the editor
useEffect(() => {
if (!reorderedPages || !editedDocument) return;
setEditedDocument({
...editedDocument,
pages: reorderedPages,
});
clearReorderedPages();
}, [reorderedPages, editedDocument, clearReorderedPages]);
// Cache page positions to help future insertions preserve intent
useEffect(() => {
if (!editedDocument) return;
const positionCache = pagePositionCacheRef.current;
const neighborCache = pageNeighborCacheRef.current;
const pages = editedDocument.pages;
pages.forEach((page, index) => {
positionCache.set(page.id, index);
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
});
}, [editedDocument]);
const fileOrderKey = useMemo(() => fileOrder.join(","), [fileOrder]);
const mergedDocSignature = useMemo(() => {
if (!mergedPdfDocument?.pages) return "";
return mergedPdfDocument.pages.map((page) => page.id).join(",");
}, [mergedPdfDocument]);
// Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing)
useEffect(() => {
if (!mergedPdfDocument || !editedDocument) return;
const sourcePages = mergedPdfDocument.pages;
const sourceIds = new Set(sourcePages.map((p) => p.id));
const prevIds = new Set(editedDocument.pages.map((p) => p.id));
const newPages: PDFPage[] = [];
for (const page of sourcePages) {
if (!prevIds.has(page.id)) {
newPages.push(page);
}
}
const hasAdditions = newPages.length > 0;
let hasRemovals = false;
for (const page of editedDocument.pages) {
if (!sourceIds.has(page.id)) {
hasRemovals = true;
break;
}
}
if (!hasAdditions && !hasRemovals) return;
setEditedDocument((prev) => {
if (!prev) return prev;
let pages = [...prev.pages];
const placeholderPositions = new Map<FileId, number>();
pages.forEach((page, index) => {
if (page.isPlaceholder && page.originalFileId) {
placeholderPositions.set(page.originalFileId, index);
}
});
const nextInsertIndexByFile = new Map(placeholderPositions);
if (hasRemovals) {
pages = pages.filter((page) => sourceIds.has(page.id));
}
if (hasAdditions) {
const mergedIndexMap = new Map<string, number>();
sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index));
const additions = newPages
.map((page) => ({
page,
cachedIndex: pagePositionCacheRef.current.get(page.id),
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
neighborId: pageNeighborCacheRef.current.get(page.id),
}))
.sort((a, b) => {
const aIndex = a.cachedIndex ?? a.mergedIndex;
const bIndex = b.cachedIndex ?? b.mergedIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
return a.mergedIndex - b.mergedIndex;
});
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
if (pages.some((existing) => existing.id === page.id)) {
return;
}
let insertIndex: number;
const originalFileId = page.originalFileId;
const placeholderIndex =
originalFileId !== undefined
? nextInsertIndexByFile.get(originalFileId)
: undefined;
if (originalFileId && placeholderIndex !== undefined) {
insertIndex = Math.min(placeholderIndex, pages.length);
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
} else if (neighborId === null) {
insertIndex = 0;
} else if (neighborId) {
const neighborIndex = pages.findIndex((p) => p.id === neighborId);
if (neighborIndex !== -1) {
insertIndex = neighborIndex + 1;
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
const clonedPage = { ...page };
pages.splice(insertIndex, 0, clonedPage);
});
}
pages = pages.map((page, index) => ({ ...page, pageNumber: index + 1 }));
return { ...prev, pages };
});
}, [mergedPdfDocument, editedDocument, fileOrderKey, mergedDocSignature]);
const displayDocument = editedDocument || initialDocument;
useEffect(() => {
updateCurrentPages(displayDocument?.pages ?? null);
}, [displayDocument, updateCurrentPages]);
return {
editedDocument,
setEditedDocument,
displayDocument,
};
};
export type UseEditedDocumentStateReturn = ReturnType<
typeof useEditedDocumentState
>;

View File

@ -0,0 +1,429 @@
import { useCallback } from "react";
import {
BulkRotateCommand,
DeletePagesCommand,
PageBreakCommand,
ReorderPagesCommand,
SplitCommand,
} from "@app/components/pageEditor/commands/pageCommands";
import type {
useFileActions,
useFileState,
} from "@app/contexts/FileContext";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
type FileActions = ReturnType<typeof useFileActions>["actions"];
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
interface UsePageEditorCommandsParams {
displayDocument: PDFDocument | null;
editedDocument: PDFDocument | null;
setEditedDocument: React.Dispatch<React.SetStateAction<PDFDocument | null>>;
splitPositions: Set<number>;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
selectedPageIds: string[];
setSelectedPageIds: (ids: string[]) => void;
getPageNumbersFromIds: (pageIds: string[]) => number[];
getPageIdsFromNumbers: (pageNumbers: number[]) => string[];
executeCommandWithTracking: (command: any) => void;
updateFileOrderFromPages: (pages: PDFPage[]) => void;
actions: FileActions;
selectors: FileSelectors;
setSelectionMode: (enabled: boolean) => void;
clearUndoHistory: () => void;
}
export const usePageEditorCommands = ({
displayDocument,
editedDocument,
setEditedDocument,
splitPositions,
setSplitPositions,
selectedPageIds,
setSelectedPageIds,
getPageNumbersFromIds,
getPageIdsFromNumbers,
executeCommandWithTracking,
updateFileOrderFromPages,
actions,
selectors,
setSelectionMode,
clearUndoHistory,
}: UsePageEditorCommandsParams) => {
const closePdf = useCallback(() => {
actions.clearAllFiles();
clearUndoHistory();
setSelectedPageIds([]);
setSelectionMode(false);
}, [actions, clearUndoHistory, setSelectedPageIds, setSelectionMode]);
const handleRotatePages = useCallback(
(pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
},
[executeCommandWithTracking]
);
const createRotateCommand = useCallback(
(pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
},
}),
[executeCommandWithTracking]
);
const createDeleteCommand = useCallback(
(pageIds: string[]) => ({
execute: () => {
if (!displayDocument) return;
const pagesToDelete = pageIds
.map((pageId) => {
const page = displayDocument.pages.find((p) => p.id === pageId);
return page?.pageNumber || 0;
})
.filter((num) => num > 0);
if (pagesToDelete.length > 0) {
const deleteCommand = new DeletePagesCommand(
pagesToDelete,
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
() => closePdf()
);
executeCommandWithTracking(deleteCommand);
}
},
}),
[
closePdf,
displayDocument,
executeCommandWithTracking,
getPageIdsFromNumbers,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
]
);
const createSplitCommand = useCallback(
(position: number) => ({
execute: () => {
const splitCommand = new SplitCommand(
position,
() => splitPositions,
setSplitPositions
);
executeCommandWithTracking(splitCommand);
},
}),
[splitPositions, executeCommandWithTracking, setSplitPositions]
);
const executeCommand = useCallback((command: any) => {
if (command && typeof command.execute === "function") {
command.execute();
}
}, []);
const handleRotate = useCallback(
(direction: "left" | "right") => {
if (!displayDocument || selectedPageIds.length === 0) return;
const rotation = direction === "left" ? -90 : 90;
handleRotatePages(selectedPageIds, rotation);
},
[displayDocument, selectedPageIds, handleRotatePages]
);
const handleDelete = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const deleteCommand = new DeletePagesCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => selectedPageNumbers,
() => closePdf()
);
executeCommandWithTracking(deleteCommand);
}, [
closePdf,
displayDocument,
executeCommandWithTracking,
getPageIdsFromNumbers,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
]);
const handleDeletePage = useCallback(
(pageNumber: number) => {
if (!displayDocument) return;
const deleteCommand = new DeletePagesCommand(
[pageNumber],
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
() => closePdf()
);
executeCommandWithTracking(deleteCommand);
},
[
closePdf,
displayDocument,
executeCommandWithTracking,
getPageIdsFromNumbers,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
]
);
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);
}
});
if (selectedPositions.length === 0) return;
const existingSplitsCount = selectedPositions.filter((pos) =>
splitPositions.has(pos)
).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount;
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
selectedPositions.forEach((pos) => newSplitPositions.delete(pos));
} else {
selectedPositions.forEach((pos) => newSplitPositions.add(pos));
}
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`,
};
executeCommandWithTracking(smartSplitCommand);
}, [
selectedPageIds,
displayDocument,
splitPositions,
setSplitPositions,
getPageNumbersFromIds,
executeCommandWithTracking,
]);
const handleSplitAll = handleSplit;
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [
displayDocument,
executeCommandWithTracking,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
]);
const handlePageBreakAll = handlePageBreak;
const handleInsertFiles = useCallback(
async (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean
) => {
if (!editedDocument || files.length === 0) return;
try {
const targetPage = editedDocument.pages.find(
(p) => p.pageNumber === insertAfterPage
);
if (!targetPage) return;
const insertAfterPageId = targetPage.id;
let addedFileIds: FileId[] = [];
if (isFromStorage) {
const stubs = files as StirlingFileStub[];
const result = await actions.addStirlingFileStubs(stubs, {
selectFiles: true,
insertAfterPageId,
});
addedFileIds = result.map((file) => file.fileId);
} else {
const result = await actions.addFiles(files as File[], {
selectFiles: true,
insertAfterPageId,
});
addedFileIds = result.map((file) => file.fileId);
}
await new Promise((resolve) => setTimeout(resolve, 100));
const newPages: PDFPage[] = [];
for (const fileId of addedFileIds) {
const stub = selectors.getStirlingFileStub(fileId);
if (stub?.processedFile?.pages) {
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
...page,
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
pageNumber: page.pageNumber ?? idx + 1,
originalFileId: fileId,
originalPageNumber:
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
rotation: page.rotation ?? 0,
thumbnail: page.thumbnail ?? null,
selected: false,
splitAfter: page.splitAfter ?? false,
}));
newPages.push(...clonedPages);
}
}
if (newPages.length > 0) {
const targetIndex = editedDocument.pages.findIndex(
(p) => p.id === targetPage.id
);
if (targetIndex >= 0) {
const updatedPages = [...editedDocument.pages];
updatedPages.splice(targetIndex + 1, 0, ...newPages);
updatedPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
setEditedDocument({
...editedDocument,
pages: updatedPages,
});
updateFileOrderFromPages(updatedPages);
}
}
} catch (error) {
console.error("Failed to insert files:", error);
}
},
[
editedDocument,
actions,
selectors,
updateFileOrderFromPages,
setEditedDocument,
]
);
const handleReorderPages = useCallback(
(
sourcePageNumber: number,
targetIndex: number,
draggedPageIds?: string[]
) => {
if (!displayDocument) return;
const selectedPages = draggedPageIds
? getPageNumbersFromIds(draggedPageIds)
: undefined;
const reorderCommand = new ReorderPagesCommand(
sourcePageNumber,
targetIndex,
selectedPages,
() => displayDocument,
setEditedDocument,
(newPages) => updateFileOrderFromPages(newPages)
);
executeCommandWithTracking(reorderCommand);
},
[
displayDocument,
executeCommandWithTracking,
getPageNumbersFromIds,
setEditedDocument,
updateFileOrderFromPages,
]
);
return {
createRotateCommand,
createDeleteCommand,
createSplitCommand,
executeCommand,
handleRotate,
handleDelete,
handleDeletePage,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleInsertFiles,
handleReorderPages,
closePdf,
};
};
export type UsePageEditorCommandsReturn = ReturnType<
typeof usePageEditorCommands
>;

View File

@ -0,0 +1,228 @@
import { useCallback } from "react";
import type {
useFileActions,
useFileState,
} from "@app/contexts/FileContext";
import { documentManipulationService } from "@app/services/documentManipulationService";
import { pdfExportService } from "@app/services/pdfExportService";
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
import { FileId } from "@app/types/file";
import { PDFDocument } from "@app/types/pageEditor";
type FileActions = ReturnType<typeof useFileActions>["actions"];
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
interface UsePageEditorExportParams {
displayDocument: PDFDocument | null;
selectedPageIds: string[];
splitPositions: Set<number>;
selectedFileIds: FileId[];
selectors: FileSelectors;
actions: FileActions;
setHasUnsavedChanges: (dirty: boolean) => void;
exportLoading: boolean;
setExportLoading: (loading: boolean) => void;
}
export const usePageEditorExport = ({
displayDocument,
selectedPageIds,
splitPositions,
selectedFileIds,
selectors,
actions,
setHasUnsavedChanges,
exportLoading,
setExportLoading,
}: UsePageEditorExportParams) => {
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
const sourceFiles = new Map<FileId, File>();
selectedFileIds.forEach((fileId) => {
const file = selectors.getFile(fileId);
if (file) {
sourceFiles.set(fileId, file);
}
});
const hasInsertedFiles = false;
const hasMultipleOriginalFiles = selectedFileIds.length > 1;
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
return null;
}
return sourceFiles.size > 0 ? sourceFiles : null;
}, [selectedFileIds, selectors]);
const getExportFilename = useCallback((): string => {
if (selectedFileIds.length <= 1) {
return displayDocument?.name || "document.pdf";
}
const firstFile = selectors.getFile(selectedFileIds[0]);
if (firstFile) {
const baseName = firstFile.name.replace(/\.pdf$/i, "");
return `${baseName} (merged).pdf`;
}
return "merged-document.pdf";
}, [selectedFileIds, selectors, displayDocument]);
const onExportSelected = useCallback(async () => {
if (!displayDocument || selectedPageIds.length === 0) return;
setExportLoading(true);
try {
const processedDocuments =
documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
const documentWithDOMState = Array.isArray(processedDocuments)
? processedDocuments[0]
: processedDocuments;
const validSelectedPageIds = selectedPageIds.filter((pageId) =>
documentWithDOMState.pages.some((page) => page.id === pageId)
);
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
documentWithDOMState,
sourceFiles,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
)
: await pdfExportService.exportPDF(
documentWithDOMState,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
);
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error("Export failed:", error);
setExportLoading(false);
}
}, [
displayDocument,
selectedPageIds,
splitPositions,
getSourceFiles,
getExportFilename,
setHasUnsavedChanges,
setExportLoading,
]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
const processedDocuments =
documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(
processedDocuments,
sourceFiles,
exportFilename
);
if (files.length > 1) {
const JSZip = await import("jszip");
const zip = new JSZip.default();
files.forEach((file) => {
zip.file(file.name, file);
});
const zipBlob = await zip.generateAsync({ type: "blob" });
const zipFilename = exportFilename.replace(/\.pdf$/i, ".zip");
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
const file = files[0];
pdfExportService.downloadFile(file, file.name);
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error("Export failed:", error);
setExportLoading(false);
}
}, [
displayDocument,
splitPositions,
getSourceFiles,
getExportFilename,
setHasUnsavedChanges,
setExportLoading,
]);
const applyChanges = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
const processedDocuments =
documentManipulationService.applyDOMChangesToDocument(
displayDocument,
displayDocument,
splitPositions
);
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(
processedDocuments,
sourceFiles,
exportFilename
);
const newStirlingFiles = await actions.addFiles(files, {
selectFiles: true,
});
if (newStirlingFiles.length > 0) {
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error("Apply changes failed:", error);
setExportLoading(false);
}
}, [
displayDocument,
splitPositions,
getSourceFiles,
getExportFilename,
actions,
setHasUnsavedChanges,
setExportLoading,
]);
return {
exportLoading,
onExportSelected,
onExportAll,
applyChanges,
};
};
export type UsePageEditorExportReturn = ReturnType<typeof usePageEditorExport>;

View File

@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { PDFDocument } from "@app/types/pageEditor";
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
interface UsePageSelectionManagerParams {
displayDocument: PDFDocument | null;
selectedPageIds: string[];
setSelectedPageIds: (ids: string[]) => void;
setSelectionMode: (enabled: boolean) => void;
toggleSelectAll: (ids: string[]) => void;
activeFilesSignature: string;
}
export const usePageSelectionManager = ({
displayDocument,
selectedPageIds,
setSelectedPageIds,
setSelectionMode,
toggleSelectAll,
activeFilesSignature,
}: UsePageSelectionManagerParams) => {
const [csvInput, setCsvInput] = useState<string>("");
const hasInitializedSelection = useRef(false);
const previousPageIdsRef = useRef<Set<string>>(new Set());
const totalPages = displayDocument?.pages.length ?? 0;
const getPageNumbersFromIds = useCallback(
(pageIds: string[]) => {
if (!displayDocument) return [];
return pageIds
.map((id) => {
const page = displayDocument.pages.find((p) => p.id === id);
return page?.pageNumber || 0;
})
.filter((num) => num > 0);
},
[displayDocument]
);
const getPageIdsFromNumbers = useCallback(
(pageNumbers: number[]) => {
if (!displayDocument) return [];
return pageNumbers
.map((num) => {
const page = displayDocument.pages.find((p) => p.pageNumber === num);
return page?.id || "";
})
.filter((id) => id !== "");
},
[displayDocument]
);
useEffect(() => {
if (
displayDocument &&
displayDocument.pages.length > 0 &&
!hasInitializedSelection.current
) {
const allPageIds = displayDocument.pages.map((page) => page.id);
setSelectedPageIds(allPageIds);
setSelectionMode(true);
hasInitializedSelection.current = true;
}
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
useEffect(() => {
if (!displayDocument || displayDocument.pages.length === 0) {
previousPageIdsRef.current = new Set();
return;
}
const currentIds = new Set(displayDocument.pages.map((page) => page.id));
const newlyAddedPageIds: string[] = [];
currentIds.forEach((id) => {
if (!previousPageIdsRef.current.has(id)) {
newlyAddedPageIds.push(id);
}
});
if (newlyAddedPageIds.length > 0) {
const next = new Set(selectedPageIds);
newlyAddedPageIds.forEach((id) => next.add(id));
setSelectedPageIds(Array.from(next));
}
previousPageIdsRef.current = currentIds;
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
useEffect(() => {
setCsvInput("");
}, [activeFilesSignature]);
const handleSelectAll = useCallback(() => {
if (!displayDocument) return;
const allPageIds = displayDocument.pages.map((page) => page.id);
toggleSelectAll(allPageIds);
}, [displayDocument, toggleSelectAll]);
const handleDeselectAll = useCallback(() => {
setSelectedPageIds([]);
}, [setSelectedPageIds]);
const handleSetSelectedPages = useCallback(
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
[getPageIdsFromNumbers, setSelectedPageIds]
);
const updatePagesFromCSV = useCallback(
(override?: string) => {
if (totalPages === 0) return;
const normalized = parseSelection(override ?? csvInput, totalPages);
handleSetSelectedPages(normalized);
},
[csvInput, totalPages, handleSetSelectedPages]
);
return {
csvInput,
setCsvInput,
totalPages,
getPageNumbersFromIds,
getPageIdsFromNumbers,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
updatePagesFromCSV,
};
};
export type UsePageSelectionManagerReturn = ReturnType<
typeof usePageSelectionManager
>;

View File

@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { UndoManager } from "@app/components/pageEditor/commands/pageCommands";
interface UseUndoManagerStateParams {
setHasUnsavedChanges: (dirty: boolean) => void;
}
export const useUndoManagerState = ({
setHasUnsavedChanges,
}: UseUndoManagerStateParams) => {
const undoManagerRef = useRef(new UndoManager());
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const updateUndoRedoState = useCallback(() => {
const undoManager = undoManagerRef.current;
setCanUndo(undoManager.canUndo());
setCanRedo(undoManager.canRedo());
if (!undoManager.hasHistory()) {
setHasUnsavedChanges(false);
}
}, [setHasUnsavedChanges]);
useEffect(() => {
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
updateUndoRedoState();
}, [updateUndoRedoState]);
const executeCommandWithTracking = useCallback(
(command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
},
[setHasUnsavedChanges]
);
const handleUndo = useCallback(() => {
undoManagerRef.current.undo();
}, []);
const handleRedo = useCallback(() => {
undoManagerRef.current.redo();
}, []);
const clearUndoHistory = useCallback(() => {
undoManagerRef.current.clear();
updateUndoRedoState();
}, [updateUndoRedoState]);
return {
canUndo,
canRedo,
executeCommandWithTracking,
handleUndo,
handleRedo,
clearUndoHistory,
};
};
export type UseUndoManagerStateReturn = ReturnType<typeof useUndoManagerState>;