add file after page

This commit is contained in:
Reece Browne 2025-08-26 02:30:29 +01:00
parent 6de5f92e83
commit 4b2e868aad
9 changed files with 436 additions and 39 deletions

View File

@ -385,6 +385,19 @@ const PageEditor = ({
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageNumbers, displayDocument]);
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
if (!displayDocument || files.length === 0) return;
try {
const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage);
if (!targetPage) return;
await actions.addFiles(files, { insertAfterPageId: targetPage.id });
} catch (error) {
console.error('Failed to insert files:', error);
}
}, [displayDocument, actions]);
const handleSelectAll = useCallback(() => {
if (!displayDocument) return;
const allPageNumbers = Array.from({ length: displayDocument.pages.length }, (_, i) => i + 1);
@ -416,12 +429,7 @@ const PageEditor = ({
const getSourceFiles = useCallback((): Map<string, File> | null => {
const sourceFiles = new Map<string, File>();
// Check if we have multiple files by looking at active file IDs
if (activeFileIds.length <= 1) {
return null; // Use single-file export method
}
// Collect all source files
// Always include original files
activeFileIds.forEach(fileId => {
const file = selectors.getFile(fileId);
if (file) {
@ -429,6 +437,14 @@ const PageEditor = ({
}
});
// Use multi-file export if we have multiple original files
const hasInsertedFiles = false;
const hasMultipleOriginalFiles = activeFileIds.length > 1;
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
return null; // Use single-file export method
}
return sourceFiles.size > 0 ? sourceFiles : null;
}, [activeFileIds, selectors]);
@ -755,6 +771,7 @@ const PageEditor = ({
pdfDocument={displayDocument}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
/>
)}
/>

View File

@ -6,9 +6,11 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import AddIcon from '@mui/icons-material/Add';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import styles from './PageEditor.module.css';
@ -35,6 +37,7 @@ interface PageThumbnailProps {
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<number>;
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
}
const PageThumbnail: React.FC<PageThumbnailProps> = ({
@ -60,6 +63,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pdfDocument,
setPdfDocument,
splitPositions,
onInsertFiles,
}: PageThumbnailProps) => {
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
@ -67,6 +71,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const dragElementRef = useRef<HTMLDivElement>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
const { openFilesModal } = useFilesModalContext();
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
@ -224,6 +229,27 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
onSetStatus(`Split marker ${action} after position ${index + 1}`);
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (onInsertFiles) {
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (files: File[], insertAfterPage?: number) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage);
}
}
});
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
} else {
// Fallback to normal file handling
openFilesModal({ insertAfterPage: page.pageNumber });
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
}
}, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]);
// Handle click vs drag differentiation
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsMouseDown(true);
@ -509,6 +535,17 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Insert File After">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleInsertFileAfter}
>
<AddIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</div>
</div>

View File

@ -568,6 +568,268 @@ export class BulkPageBreakCommand extends DOMCommand {
}
}
export class InsertFilesCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
private fileDataMap = new Map<string, ArrayBuffer>(); // Store file data for thumbnail generation
private originalProcessedFile: any = null; // Store original ProcessedFile for undo
private insertedFileMap = new Map<string, File>(); // Store inserted files for export
constructor(
private files: File[],
private insertAfterPageNumber: number,
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private getSelectedPages: () => number[],
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<string, File>) => void
) {
super();
}
async execute(): Promise<void> {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.files.length === 0) return;
// Store original state for undo
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page}))
};
try {
// Process each file to extract pages and wait for all to complete
const allNewPages: PDFPage[] = [];
// Process all files and wait for their completion
const baseTimestamp = Date.now();
const extractionPromises = this.files.map(async (file, index) => {
const fileId = `inserted-${file.name}-${baseTimestamp + index}`;
// Store inserted file for export
this.insertedFileMap.set(fileId, file);
// Use base timestamp + index to ensure unique but predictable file IDs
return await this.extractPagesFromFile(file, baseTimestamp + index);
});
const extractedPageArrays = await Promise.all(extractionPromises);
// Flatten all extracted pages
for (const pages of extractedPageArrays) {
allNewPages.push(...pages);
}
if (allNewPages.length === 0) return;
// Find insertion point (after the specified page)
const insertIndex = this.insertAfterPageNumber; // Insert after page N means insert at index N
// Create new pages array with inserted pages
const newPages: PDFPage[] = [];
let pageNumberCounter = 1;
// Add pages before insertion point
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
newPages.push(page);
}
// Add inserted pages
for (const newPage of allNewPages) {
const insertedPage: PDFPage = {
...newPage,
pageNumber: pageNumberCounter++,
selected: false,
splitAfter: false
};
newPages.push(insertedPage);
this.insertedPages.push(insertedPage);
}
// Add remaining pages after insertion point
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
newPages.push(page);
}
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(updatedDocument);
// Update FileContext with the new document structure and inserted files
if (this.updateFileContext) {
this.updateFileContext(updatedDocument, this.insertedFileMap);
}
// Generate thumbnails for inserted pages (all files should be read by now)
this.generateThumbnailsForInsertedPages(updatedDocument);
// Maintain existing selection by mapping original selected pages to their new positions
const originalSelection = this.getSelectedPages();
const updatedSelection: number[] = [];
originalSelection.forEach(originalPageNum => {
if (originalPageNum <= this.insertAfterPageNumber) {
// Pages before insertion point keep same number
updatedSelection.push(originalPageNum);
} else {
// Pages after insertion point are shifted by number of inserted pages
updatedSelection.push(originalPageNum + allNewPages.length);
}
});
this.setSelectedPages(updatedSelection);
} catch (error) {
console.error('Failed to insert files:', error);
// Revert to original state if error occurs
if (this.originalDocument) {
this.setDocument(this.originalDocument);
}
}
}
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
try {
const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService');
// Group pages by file ID to generate thumbnails efficiently
const pagesByFileId = new Map<string, PDFPage[]>();
for (const page of this.insertedPages) {
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-'));
if (!pagesByFileId.has(fileId)) {
pagesByFileId.set(fileId, []);
}
pagesByFileId.get(fileId)!.push(page);
}
// Generate thumbnails for each file
for (const [fileId, pages] of pagesByFileId) {
const arrayBuffer = this.fileDataMap.get(fileId);
console.log('Generating thumbnails for file:', fileId);
console.log('Pages:', pages.length);
console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (arrayBuffer && arrayBuffer.byteLength > 0) {
// Extract page numbers for all pages from this file
const pageNumbers = pages.map(page => {
const pageNumMatch = page.id.match(/-page-(\d+)$/);
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
});
console.log('Generating thumbnails for page numbers:', pageNumbers);
// Generate thumbnails for all pages from this file at once
const results = await thumbnailGenerationService.generateThumbnails(
fileId,
arrayBuffer,
pageNumbers,
{ scale: 0.2, quality: 0.8 }
);
console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
// Update pages with generated thumbnails
for (let i = 0; i < results.length && i < pages.length; i++) {
const result = results[i];
const page = pages[i];
if (result.success) {
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
if (pageIndex >= 0) {
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
console.log('Updated thumbnail for page:', page.id);
}
}
}
// Trigger re-render by updating the document
this.setDocument({ ...updatedDocument });
} else {
console.error('No valid ArrayBuffer found for file ID:', fileId);
}
}
} catch (error) {
console.error('Failed to generate thumbnails for inserted pages:', error);
}
}
private async extractPagesFromFile(file: File, baseTimestamp: number): Promise<PDFPage[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const arrayBuffer = event.target?.result as ArrayBuffer;
console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (!arrayBuffer) {
reject(new Error('Failed to read file'));
return;
}
// Clone the ArrayBuffer before passing to PDF.js (it might consume it)
const clonedArrayBuffer = arrayBuffer.slice(0);
// Use PDF.js via the worker manager to extract pages
const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
const pageCount = pdf.numPages;
const pages: PDFPage[] = [];
const fileId = `inserted-${file.name}-${baseTimestamp}`;
console.log('Original ArrayBuffer size:', arrayBuffer.byteLength);
console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength);
// Store the original ArrayBuffer for thumbnail generation
this.fileDataMap.set(fileId, arrayBuffer);
console.log('After storing - fileDataMap size:', this.fileDataMap.size);
console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined');
for (let i = 1; i <= pageCount; i++) {
const pageId = `${fileId}-page-${i}`;
pages.push({
id: pageId,
pageNumber: i, // Will be renumbered in execute()
originalPageNumber: i,
thumbnail: null, // Will be generated after insertion
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: false
});
}
// Clean up PDF document
pdfWorkerManager.destroyDocument(pdf);
resolve(pages);
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsArrayBuffer(file);
});
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert ${this.files.length} file(s) after page ${this.insertAfterPageNumber}`;
}
}
// Simple undo manager for DOM commands
export class UndoManager {
private undoStack: DOMCommand[] = [];
@ -585,6 +847,13 @@ export class UndoManager {
this.onStateChange?.();
}
// For async commands that need to be executed manually
addToUndoStack(command: DOMCommand): void {
this.undoStack.push(command);
this.redoStack = [];
this.onStateChange?.();
}
undo(): boolean {
const command = this.undoStack.pop();
if (command) {

View File

@ -49,33 +49,41 @@ export function usePageDocument(): PageDocumentHook {
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
// Debug logging for merged document creation
console.log(`🎬 PageEditor: Building merged document for ${name} with ${activeFileIds.length} files`);
// Build page insertion map from files with insertion positions
const insertionMap = new Map<string, string[]>(); // insertAfterPageId -> fileIds
const originalFileIds: string[] = [];
// Collect pages from ALL active files, not just the primary file
activeFileIds.forEach(fileId => {
const record = selectors.getFileRecord(fileId);
if (record?.insertAfterPageId !== undefined) {
if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []);
}
insertionMap.get(record.insertAfterPageId)!.push(fileId);
} else {
originalFileIds.push(fileId);
}
});
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
let totalPageCount = 0;
activeFileIds.forEach((fileId, fileIndex) => {
// Helper function to create pages from a file
const createPagesFromFile = (fileId: string, startPageNumber: number): PDFPage[] => {
const fileRecord = selectors.getFileRecord(fileId);
if (!fileRecord) {
console.warn(`🎬 PageEditor: No record found for file ${fileId}`);
return;
return [];
}
const processedFile = fileRecord.processedFile;
console.log(`🎬 PageEditor: Processing file ${fileIndex + 1}/${activeFileIds.length} (${fileRecord.name})`);
console.log(`🎬 ProcessedFile exists:`, !!processedFile);
console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0);
console.log(`🎬 ProcessedFile totalPages:`, processedFile?.totalPages || 'unknown');
let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) {
// Use fully processed pages with thumbnails
filePages = processedFile.pages.map((page, pageIndex) => ({
id: `${fileId}-${page.pageNumber}`,
pageNumber: totalPageCount + pageIndex + 1,
pageNumber: startPageNumber + pageIndex,
thumbnail: page.thumbnail || null,
rotation: page.rotation || 0,
selected: false,
@ -85,30 +93,62 @@ export function usePageDocument(): PageDocumentHook {
}));
} else if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count
console.log(`🎬 PageEditor: Creating placeholder pages for ${fileRecord.name} (${processedFile.totalPages} pages)`);
filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
id: `${fileId}-${pageIndex + 1}`,
pageNumber: totalPageCount + pageIndex + 1,
pageNumber: startPageNumber + pageIndex,
originalPageNumber: pageIndex + 1,
originalFileId: fileId,
rotation: 0,
thumbnail: null, // Will be generated later
thumbnail: null,
selected: false,
splitAfter: false,
}));
}
pages = pages.concat(filePages);
totalPageCount += filePages.length;
return filePages;
};
// Collect all pages from original files (without renumbering yet)
const originalFilePages: PDFPage[] = [];
originalFileIds.forEach(fileId => {
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
originalFilePages.push(...filePages);
});
// Start with all original pages numbered sequentially
pages = originalFilePages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
// Process each insertion by finding the page ID and inserting after it
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId);
if (targetPageIndex === -1) continue;
// Collect all pages to insert
const allNewPages: PDFPage[] = [];
fileIds.forEach(fileId => {
const insertedPages = createPagesFromFile(fileId, 1);
allNewPages.push(...insertedPages);
});
// Insert all new pages after the target page
pages.splice(targetPageIndex + 1, 0, ...allNewPages);
// Renumber all pages after insertion
pages.forEach((page, index) => {
page.pageNumber = index + 1;
});
}
totalPageCount = pages.length;
if (pages.length === 0) {
console.warn('🎬 PageEditor: No pages found in any files');
return null;
}
console.log(`🎬 PageEditor: Created merged document with ${pages.length} total pages`);
const mergedDoc: PDFDocument = {
id: activeFileIds.join('-'),
name,

View File

@ -25,7 +25,7 @@ const FileStatusIndicator = ({
{t("files.noFiles", "No files uploaded. ")}{" "}
<Anchor
size="sm"
onClick={openFilesModal}
onClick={() => openFilesModal()}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<FolderIcon style={{ fontSize: '14px' }} />
@ -42,7 +42,7 @@ const FileStatusIndicator = ({
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={openFilesModal}
onClick={() => openFilesModal()}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<FolderIcon style={{ fontSize: '14px' }} />

View File

@ -73,8 +73,8 @@ function FileContextInner({
}, []);
// File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[]): Promise<File[]> => {
const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch, lifecycleManager);
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<File[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Persist to IndexedDB if enabled
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {

View File

@ -4,7 +4,7 @@ import { FileMetadata } from '../types/file';
interface FilesModalContextType {
isFilesModalOpen: boolean;
openFilesModal: () => void;
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
closeFilesModal: () => void;
onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void;
@ -19,30 +19,55 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
const openFilesModal = useCallback(() => {
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => {
setInsertAfterPage(options?.insertAfterPage);
setCustomHandler(() => options?.customHandler);
setIsFilesModalOpen(true);
}, []);
const closeFilesModal = useCallback(() => {
setIsFilesModalOpen(false);
setInsertAfterPage(undefined); // Clear insertion position
setCustomHandler(undefined); // Clear custom handler
onModalClose?.();
}, [onModalClose]);
const handleFileSelect = useCallback((file: File) => {
addToActiveFiles(file);
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler([file], insertAfterPage);
} else {
// Use normal file handling
addToActiveFiles(file);
}
closeFilesModal();
}, [addToActiveFiles, closeFilesModal]);
}, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleFilesSelect = useCallback((files: File[]) => {
addMultipleFiles(files);
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler(files, insertAfterPage);
} else {
// Use normal file handling
addMultipleFiles(files);
}
closeFilesModal();
}, [addMultipleFiles, closeFilesModal]);
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
addStoredFiles(filesWithMetadata);
if (customHandler) {
// Use custom handler for special cases (like page insertion)
const files = filesWithMetadata.map(item => item.file);
customHandler(files, insertAfterPage);
} else {
// Use normal file handling
addStoredFiles(filesWithMetadata);
}
closeFilesModal();
}, [addStoredFiles, closeFilesModal]);
}, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);
const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback);

View File

@ -84,6 +84,9 @@ interface AddFileOptions {
// For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
// Insertion position
insertAfterPageId?: string;
}
/**
@ -164,6 +167,11 @@ export async function addFiles(
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create initial processedFile metadata with page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);

View File

@ -55,6 +55,7 @@ export interface FileRecord {
blobUrl?: string;
createdAt?: number;
processedFile?: ProcessedFileMetadata;
insertAfterPageId?: string; // Page ID after which this file should be inserted
isPinned?: boolean;
// Note: File object stored in provider ref, not in state
}
@ -216,7 +217,7 @@ export type FileContextAction =
export interface FileContextActions {
// File management - lightweight actions only
addFiles: (files: File[]) => Promise<File[]>;
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<File[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;