mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-29 15:49:22 +00:00

# Description of Changes The `FileId` type in V2 currently is just defined to be a string. This makes it really easy to accidentally pass strings into things accepting file IDs (such as file names). This PR makes the `FileId` type [an opaque type](https://www.geeksforgeeks.org/typescript/opaque-types-in-typescript/), so it is compatible with things accepting strings (arguably not ideal for this...) but strings are not compatible with it without explicit conversion. The PR also includes changes to use `FileId` consistently throughout the project (everywhere I could find uses of `fileId: string`), so that we have the maximum benefit from the type safety. > [!note] > I've marked quite a few things as `FIX ME` where we're passing names in as IDs. If that is intended behaviour, I'm happy to remove the fix me and insert a cast instead, but they probably need comments explaining why we're using a file name as an ID.
881 lines
28 KiB
TypeScript
881 lines
28 KiB
TypeScript
import { FileId } from '../../../types/file';
|
|
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
|
|
|
|
// V1-style DOM-first command system (replaces the old React state commands)
|
|
export abstract class DOMCommand {
|
|
abstract execute(): void;
|
|
abstract undo(): void;
|
|
abstract description: string;
|
|
}
|
|
|
|
export class RotatePageCommand extends DOMCommand {
|
|
constructor(
|
|
private pageId: string,
|
|
private degrees: number
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
// Only update DOM for immediate visual feedback
|
|
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
|
if (pageElement) {
|
|
const img = pageElement.querySelector('img');
|
|
if (img) {
|
|
// Extract current rotation from transform property to match the animated CSS
|
|
const currentTransform = img.style.transform || '';
|
|
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
|
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
|
const newRotation = currentRotation + this.degrees;
|
|
img.style.transform = `rotate(${newRotation}deg)`;
|
|
}
|
|
}
|
|
}
|
|
|
|
undo(): void {
|
|
// Only update DOM
|
|
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
|
if (pageElement) {
|
|
const img = pageElement.querySelector('img');
|
|
if (img) {
|
|
// Extract current rotation from transform property
|
|
const currentTransform = img.style.transform || '';
|
|
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
|
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
|
const previousRotation = currentRotation - this.degrees;
|
|
img.style.transform = `rotate(${previousRotation}deg)`;
|
|
}
|
|
}
|
|
}
|
|
|
|
get description(): string {
|
|
return `Rotate page ${this.degrees > 0 ? 'right' : 'left'}`;
|
|
}
|
|
}
|
|
|
|
export class DeletePagesCommand extends DOMCommand {
|
|
private originalDocument: PDFDocument | null = null;
|
|
private originalSplitPositions: Set<number> = new Set();
|
|
private originalSelectedPages: number[] = [];
|
|
private hasExecuted: boolean = false;
|
|
private pageIdsToDelete: string[] = [];
|
|
|
|
constructor(
|
|
private pagesToDelete: number[],
|
|
private getCurrentDocument: () => PDFDocument | null,
|
|
private setDocument: (doc: PDFDocument) => void,
|
|
private setSelectedPages: (pages: number[]) => void,
|
|
private getSplitPositions: () => Set<number>,
|
|
private setSplitPositions: (positions: Set<number>) => void,
|
|
private getSelectedPages: () => number[]
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
const currentDoc = this.getCurrentDocument();
|
|
if (!currentDoc || this.pagesToDelete.length === 0) return;
|
|
|
|
// Store complete original state for undo (only on first execution)
|
|
if (!this.hasExecuted) {
|
|
this.originalDocument = {
|
|
...currentDoc,
|
|
pages: currentDoc.pages.map(page => ({...page})) // Deep copy pages
|
|
};
|
|
this.originalSplitPositions = new Set(this.getSplitPositions());
|
|
this.originalSelectedPages = [...this.getSelectedPages()];
|
|
|
|
// Convert page numbers to page IDs for stable identification
|
|
this.pageIdsToDelete = this.pagesToDelete.map(pageNum => {
|
|
const page = currentDoc.pages.find(p => p.pageNumber === pageNum);
|
|
return page?.id || '';
|
|
}).filter(id => id);
|
|
|
|
this.hasExecuted = true;
|
|
}
|
|
|
|
// Filter out deleted pages by ID (stable across undo/redo)
|
|
const remainingPages = currentDoc.pages.filter(page =>
|
|
!this.pageIdsToDelete.includes(page.id)
|
|
);
|
|
|
|
if (remainingPages.length === 0) return; // Safety check
|
|
|
|
// Renumber remaining pages
|
|
remainingPages.forEach((page, index) => {
|
|
page.pageNumber = index + 1;
|
|
});
|
|
|
|
// Update document
|
|
const updatedDocument: PDFDocument = {
|
|
...currentDoc,
|
|
pages: remainingPages,
|
|
totalPages: remainingPages.length,
|
|
};
|
|
|
|
// Adjust split positions
|
|
const currentSplitPositions = this.getSplitPositions();
|
|
const newPositions = new Set<number>();
|
|
currentSplitPositions.forEach(pos => {
|
|
if (pos < remainingPages.length - 1) {
|
|
newPositions.add(pos);
|
|
}
|
|
});
|
|
|
|
// Apply changes
|
|
this.setDocument(updatedDocument);
|
|
this.setSelectedPages([]);
|
|
this.setSplitPositions(newPositions);
|
|
}
|
|
|
|
undo(): void {
|
|
if (!this.originalDocument) return;
|
|
|
|
// Simply restore the complete original document state
|
|
this.setDocument(this.originalDocument);
|
|
this.setSplitPositions(this.originalSplitPositions);
|
|
this.setSelectedPages(this.originalSelectedPages);
|
|
}
|
|
|
|
get description(): string {
|
|
return `Delete ${this.pagesToDelete.length} page(s)`;
|
|
}
|
|
}
|
|
|
|
export class ReorderPagesCommand extends DOMCommand {
|
|
private originalPages: PDFPage[] = [];
|
|
|
|
constructor(
|
|
private sourcePageNumber: number,
|
|
private targetIndex: number,
|
|
private selectedPages: number[] | undefined,
|
|
private getCurrentDocument: () => PDFDocument | null,
|
|
private setDocument: (doc: PDFDocument) => void
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
const currentDoc = this.getCurrentDocument();
|
|
if (!currentDoc) return;
|
|
|
|
// Store original state for undo
|
|
this.originalPages = currentDoc.pages.map(page => ({...page}));
|
|
|
|
// Perform the reorder
|
|
const sourceIndex = currentDoc.pages.findIndex(p => p.pageNumber === this.sourcePageNumber);
|
|
if (sourceIndex === -1) return;
|
|
|
|
const newPages = [...currentDoc.pages];
|
|
|
|
if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) {
|
|
// Multi-page reorder
|
|
const selectedPageObjects = this.selectedPages
|
|
.map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum))
|
|
.filter(page => page !== undefined) as PDFPage[];
|
|
|
|
const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber));
|
|
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
|
|
|
|
remainingPages.forEach((page, index) => {
|
|
page.pageNumber = index + 1;
|
|
});
|
|
|
|
newPages.splice(0, newPages.length, ...remainingPages);
|
|
} else {
|
|
// Single page reorder
|
|
const [movedPage] = newPages.splice(sourceIndex, 1);
|
|
newPages.splice(this.targetIndex, 0, movedPage);
|
|
|
|
newPages.forEach((page, index) => {
|
|
page.pageNumber = index + 1;
|
|
});
|
|
}
|
|
|
|
const reorderedDocument: PDFDocument = {
|
|
...currentDoc,
|
|
pages: newPages,
|
|
totalPages: newPages.length,
|
|
};
|
|
|
|
this.setDocument(reorderedDocument);
|
|
}
|
|
|
|
undo(): void {
|
|
const currentDoc = this.getCurrentDocument();
|
|
if (!currentDoc || this.originalPages.length === 0) return;
|
|
|
|
// Restore original page order
|
|
const restoredDocument: PDFDocument = {
|
|
...currentDoc,
|
|
pages: this.originalPages,
|
|
totalPages: this.originalPages.length,
|
|
};
|
|
|
|
this.setDocument(restoredDocument);
|
|
}
|
|
|
|
get description(): string {
|
|
return `Reorder page(s)`;
|
|
}
|
|
}
|
|
|
|
export class SplitCommand extends DOMCommand {
|
|
private originalSplitPositions: Set<number> = new Set();
|
|
|
|
constructor(
|
|
private position: number,
|
|
private getSplitPositions: () => Set<number>,
|
|
private setSplitPositions: (positions: Set<number>) => void
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
// Store original state for undo
|
|
this.originalSplitPositions = new Set(this.getSplitPositions());
|
|
|
|
// Toggle the split position
|
|
const currentPositions = this.getSplitPositions();
|
|
const newPositions = new Set(currentPositions);
|
|
|
|
if (newPositions.has(this.position)) {
|
|
newPositions.delete(this.position);
|
|
} else {
|
|
newPositions.add(this.position);
|
|
}
|
|
|
|
this.setSplitPositions(newPositions);
|
|
}
|
|
|
|
undo(): void {
|
|
// Restore original split positions
|
|
this.setSplitPositions(this.originalSplitPositions);
|
|
}
|
|
|
|
get description(): string {
|
|
const currentPositions = this.getSplitPositions();
|
|
const willAdd = !currentPositions.has(this.position);
|
|
return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`;
|
|
}
|
|
}
|
|
|
|
export class BulkRotateCommand extends DOMCommand {
|
|
private originalRotations: Map<string, number> = new Map();
|
|
|
|
constructor(
|
|
private pageIds: string[],
|
|
private degrees: number
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
this.pageIds.forEach(pageId => {
|
|
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
|
if (pageElement) {
|
|
const img = pageElement.querySelector('img');
|
|
if (img) {
|
|
// Store original rotation for undo (only on first execution)
|
|
if (!this.originalRotations.has(pageId)) {
|
|
const currentTransform = img.style.transform || '';
|
|
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
|
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
|
this.originalRotations.set(pageId, currentRotation);
|
|
}
|
|
|
|
// Apply rotation using transform to trigger CSS animation
|
|
const currentTransform = img.style.transform || '';
|
|
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
|
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
|
const newRotation = currentRotation + this.degrees;
|
|
img.style.transform = `rotate(${newRotation}deg)`;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
undo(): void {
|
|
this.pageIds.forEach(pageId => {
|
|
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
|
if (pageElement) {
|
|
const img = pageElement.querySelector('img');
|
|
if (img && this.originalRotations.has(pageId)) {
|
|
img.style.transform = `rotate(${this.originalRotations.get(pageId)}deg)`;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
get description(): string {
|
|
return `Rotate ${this.pageIds.length} page(s) ${this.degrees > 0 ? 'right' : 'left'}`;
|
|
}
|
|
}
|
|
|
|
export class BulkSplitCommand extends DOMCommand {
|
|
private originalSplitPositions: Set<number> = new Set();
|
|
|
|
constructor(
|
|
private positions: number[],
|
|
private getSplitPositions: () => Set<number>,
|
|
private setSplitPositions: (positions: Set<number>) => void
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
// Store original state for undo (only on first execution)
|
|
if (this.originalSplitPositions.size === 0) {
|
|
this.originalSplitPositions = new Set(this.getSplitPositions());
|
|
}
|
|
|
|
// Toggle each position
|
|
const currentPositions = new Set(this.getSplitPositions());
|
|
this.positions.forEach(position => {
|
|
if (currentPositions.has(position)) {
|
|
currentPositions.delete(position);
|
|
} else {
|
|
currentPositions.add(position);
|
|
}
|
|
});
|
|
|
|
this.setSplitPositions(currentPositions);
|
|
}
|
|
|
|
undo(): void {
|
|
// Restore original split positions
|
|
this.setSplitPositions(this.originalSplitPositions);
|
|
}
|
|
|
|
get description(): string {
|
|
return `Toggle ${this.positions.length} split position(s)`;
|
|
}
|
|
}
|
|
|
|
export class SplitAllCommand extends DOMCommand {
|
|
private originalSplitPositions: Set<number> = new Set();
|
|
private allPossibleSplits: Set<number> = new Set();
|
|
|
|
constructor(
|
|
private totalPages: number,
|
|
private getSplitPositions: () => Set<number>,
|
|
private setSplitPositions: (positions: Set<number>) => void
|
|
) {
|
|
super();
|
|
// Calculate all possible split positions (between pages, not after last page)
|
|
for (let i = 0; i < this.totalPages - 1; i++) {
|
|
this.allPossibleSplits.add(i);
|
|
}
|
|
}
|
|
|
|
execute(): void {
|
|
// Store original state for undo
|
|
this.originalSplitPositions = new Set(this.getSplitPositions());
|
|
|
|
// Check if all splits are already active
|
|
const currentSplits = this.getSplitPositions();
|
|
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
|
|
|
|
if (hasAllSplits) {
|
|
// Remove all splits
|
|
this.setSplitPositions(new Set());
|
|
} else {
|
|
// Add all splits
|
|
this.setSplitPositions(this.allPossibleSplits);
|
|
}
|
|
}
|
|
|
|
undo(): void {
|
|
// Restore original split positions
|
|
this.setSplitPositions(this.originalSplitPositions);
|
|
}
|
|
|
|
get description(): string {
|
|
const currentSplits = this.getSplitPositions();
|
|
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
|
|
return hasAllSplits ? 'Remove all splits' : 'Split all pages';
|
|
}
|
|
}
|
|
|
|
export class PageBreakCommand extends DOMCommand {
|
|
private insertedPages: PDFPage[] = [];
|
|
private originalDocument: PDFDocument | null = null;
|
|
|
|
constructor(
|
|
private selectedPageNumbers: number[],
|
|
private getCurrentDocument: () => PDFDocument | null,
|
|
private setDocument: (doc: PDFDocument) => void
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
const currentDoc = this.getCurrentDocument();
|
|
if (!currentDoc || this.selectedPageNumbers.length === 0) return;
|
|
|
|
// Store original state for undo
|
|
this.originalDocument = {
|
|
...currentDoc,
|
|
pages: currentDoc.pages.map(page => ({...page}))
|
|
};
|
|
|
|
// Create new pages array with blank pages inserted
|
|
const newPages: PDFPage[] = [];
|
|
this.insertedPages = [];
|
|
let pageNumberCounter = 1;
|
|
|
|
currentDoc.pages.forEach((page, index) => {
|
|
// Add the current page
|
|
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
|
|
newPages.push(updatedPage);
|
|
|
|
// If this page is selected for page break insertion, add a blank page after it
|
|
if (this.selectedPageNumbers.includes(page.pageNumber)) {
|
|
const blankPage: PDFPage = {
|
|
id: `blank-${Date.now()}-${index}`,
|
|
pageNumber: pageNumberCounter++,
|
|
originalPageNumber: -1, // Mark as blank page
|
|
thumbnail: null,
|
|
rotation: 0,
|
|
selected: false,
|
|
splitAfter: false,
|
|
isBlankPage: true // Custom flag for blank pages
|
|
};
|
|
newPages.push(blankPage);
|
|
this.insertedPages.push(blankPage);
|
|
}
|
|
});
|
|
|
|
// Update document
|
|
const updatedDocument: PDFDocument = {
|
|
...currentDoc,
|
|
pages: newPages,
|
|
totalPages: newPages.length,
|
|
};
|
|
|
|
this.setDocument(updatedDocument);
|
|
|
|
// No need to maintain selection - page IDs remain stable, so selection persists automatically
|
|
}
|
|
|
|
undo(): void {
|
|
if (!this.originalDocument) return;
|
|
this.setDocument(this.originalDocument);
|
|
}
|
|
|
|
get description(): string {
|
|
return `Insert ${this.selectedPageNumbers.length} page break(s)`;
|
|
}
|
|
}
|
|
|
|
export class BulkPageBreakCommand extends DOMCommand {
|
|
private insertedPages: PDFPage[] = [];
|
|
private originalDocument: PDFDocument | null = null;
|
|
private originalSelectedPages: number[] = [];
|
|
|
|
constructor(
|
|
private getCurrentDocument: () => PDFDocument | null,
|
|
private setDocument: (doc: PDFDocument) => void,
|
|
private setSelectedPages: (pages: number[]) => void,
|
|
private getSelectedPages: () => number[]
|
|
) {
|
|
super();
|
|
}
|
|
|
|
execute(): void {
|
|
const currentDoc = this.getCurrentDocument();
|
|
if (!currentDoc) return;
|
|
|
|
// Store original selection to restore later
|
|
this.originalSelectedPages = this.getSelectedPages();
|
|
|
|
// Store original state for undo
|
|
this.originalDocument = {
|
|
...currentDoc,
|
|
pages: currentDoc.pages.map(page => ({...page}))
|
|
};
|
|
|
|
// Create new pages array with blank pages inserted after each page (except the last)
|
|
const newPages: PDFPage[] = [];
|
|
this.insertedPages = [];
|
|
let pageNumberCounter = 1;
|
|
|
|
currentDoc.pages.forEach((page, index) => {
|
|
// Add the current page
|
|
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
|
|
newPages.push(updatedPage);
|
|
|
|
// Add blank page after each page except the last one
|
|
if (index < currentDoc.pages.length - 1) {
|
|
const blankPage: PDFPage = {
|
|
id: `blank-${Date.now()}-${index}`,
|
|
pageNumber: pageNumberCounter++,
|
|
originalPageNumber: -1,
|
|
thumbnail: null,
|
|
rotation: 0,
|
|
selected: false,
|
|
splitAfter: false,
|
|
isBlankPage: true
|
|
};
|
|
newPages.push(blankPage);
|
|
this.insertedPages.push(blankPage);
|
|
}
|
|
});
|
|
|
|
// Update document
|
|
const updatedDocument: PDFDocument = {
|
|
...currentDoc,
|
|
pages: newPages,
|
|
totalPages: newPages.length,
|
|
};
|
|
|
|
this.setDocument(updatedDocument);
|
|
|
|
// Maintain existing selection by mapping original selected pages to their new positions
|
|
const updatedSelection: number[] = [];
|
|
this.originalSelectedPages.forEach(originalPageNum => {
|
|
// Find the original page by matching the page ID from the original document
|
|
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
|
|
if (originalPage) {
|
|
const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage);
|
|
if (foundPage) {
|
|
updatedSelection.push(foundPage.pageNumber);
|
|
}
|
|
}
|
|
});
|
|
this.setSelectedPages(updatedSelection);
|
|
}
|
|
|
|
undo(): void {
|
|
if (!this.originalDocument) return;
|
|
this.setDocument(this.originalDocument);
|
|
}
|
|
|
|
get description(): string {
|
|
return `Insert page breaks after all pages`;
|
|
}
|
|
}
|
|
|
|
export class InsertFilesCommand extends DOMCommand {
|
|
private insertedPages: PDFPage[] = [];
|
|
private originalDocument: PDFDocument | null = null;
|
|
private fileDataMap = new Map<FileId, ArrayBuffer>(); // Store file data for thumbnail generation
|
|
private originalProcessedFile: any = null; // Store original ProcessedFile for undo
|
|
private insertedFileMap = new Map<FileId, 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<FileId, 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}` as FileId;
|
|
// 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<FileId, PDFPage[]>();
|
|
|
|
for (const page of this.insertedPages) {
|
|
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */;
|
|
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}` as FileId;
|
|
|
|
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[] = [];
|
|
private redoStack: DOMCommand[] = [];
|
|
private onStateChange?: () => void;
|
|
|
|
setStateChangeCallback(callback: () => void): void {
|
|
this.onStateChange = callback;
|
|
}
|
|
|
|
executeCommand(command: DOMCommand): void {
|
|
command.execute();
|
|
this.undoStack.push(command);
|
|
this.redoStack = [];
|
|
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) {
|
|
command.undo();
|
|
this.redoStack.push(command);
|
|
this.onStateChange?.();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
redo(): boolean {
|
|
const command = this.redoStack.pop();
|
|
if (command) {
|
|
command.execute();
|
|
this.undoStack.push(command);
|
|
this.onStateChange?.();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
canUndo(): boolean {
|
|
return this.undoStack.length > 0;
|
|
}
|
|
|
|
canRedo(): boolean {
|
|
return this.redoStack.length > 0;
|
|
}
|
|
|
|
clear(): void {
|
|
this.undoStack = [];
|
|
this.redoStack = [];
|
|
this.onStateChange?.();
|
|
}
|
|
}
|