Rewrite page editor

This commit is contained in:
Reece Browne 2025-08-26 03:22:59 +01:00
parent ed61c71db7
commit 4a1c919cb8
24 changed files with 2663 additions and 2040 deletions

View File

@ -1,7 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
const { icons } = require('@iconify-json/material-symbols'); const { icons } = require('@iconify-json/material-symbols');
const { getIcons } = require('@iconify/utils');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@ -89,68 +88,73 @@ function scanForUsedIcons() {
return iconArray; return iconArray;
} }
// Auto-detect used icons // Main async function
const usedIcons = scanForUsedIcons(); async function main() {
// Auto-detect used icons
const usedIcons = scanForUsedIcons();
// Check if we need to regenerate (compare with existing) // Check if we need to regenerate (compare with existing)
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json'); const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
let needsRegeneration = true; let needsRegeneration = true;
if (fs.existsSync(outputPath)) { if (fs.existsSync(outputPath)) {
try { try {
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8')); const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const existingIcons = Object.keys(existingSet.icons || {}).sort(); const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort(); const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) { if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false; needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
}
} catch (error) {
// If we can't parse existing file, regenerate
needsRegeneration = true;
} }
} catch (error) {
// If we can't parse existing file, regenerate
needsRegeneration = true;
} }
}
if (!needsRegeneration) { if (!needsRegeneration) {
info('🎉 No regeneration needed!'); info('🎉 No regeneration needed!');
process.exit(0); process.exit(0);
} }
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`); info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
// Extract only our used icons from the full set // Dynamic import of ES module
const extractedIcons = getIcons(icons, usedIcons); const { getIcons } = await import('@iconify/utils');
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
if (!extractedIcons) { if (!extractedIcons) {
console.error('❌ Failed to extract icons'); console.error('❌ Failed to extract icons');
process.exit(1); process.exit(1);
} }
// Check for missing icons // Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.icons || {}); const extractedIconNames = Object.keys(extractedIcons.icons || {});
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon)); const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
if (missingIcons.length > 0) { if (missingIcons.length > 0) {
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`); info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.'); info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
} }
// Create output directory // Create output directory
const outputDir = path.join(__dirname, '..', 'src', 'assets'); const outputDir = path.join(__dirname, '..', 'src', 'assets');
if (!fs.existsSync(outputDir)) { if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
} }
// Write the extracted icon set to a file (outputPath already defined above) // Write the extracted icon set to a file (outputPath already defined above)
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2)); fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`); info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`); info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
info(`💾 Saved to: ${outputPath}`); info(`💾 Saved to: ${outputPath}`);
// Generate TypeScript types // Generate TypeScript types
const typesContent = `// Auto-generated icon types const typesContent = `// Auto-generated icon types
// This file is automatically generated by scripts/generate-icons.js // This file is automatically generated by scripts/generate-icons.js
// Do not edit manually - changes will be overwritten // Do not edit manually - changes will be overwritten
@ -168,8 +172,15 @@ declare const iconSet: IconSet;
export default iconSet; export default iconSet;
`; `;
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts'); const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
fs.writeFileSync(typesPath, typesContent); fs.writeFileSync(typesPath, typesContent);
info(`📝 Generated types: ${typesPath}`); info(`📝 Generated types: ${typesPath}`);
info(`🎉 Icon extraction complete!`); info(`🎉 Icon extraction complete!`);
}
// Run the main function
main().catch(error => {
console.error('❌ Script failed:', error);
process.exit(1);
});

View File

@ -1,335 +0,0 @@
import { Command, CommandSequence } from '../hooks/useUndoRedo';
import { PDFDocument, PDFPage } from '../types/pageEditor';
// Base class for page operations
abstract class PageCommand implements Command {
protected pdfDocument: PDFDocument;
protected setPdfDocument: (doc: PDFDocument) => void;
protected previousState: PDFDocument;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void
) {
this.pdfDocument = pdfDocument;
this.setPdfDocument = setPdfDocument;
this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone
}
abstract execute(): void;
abstract description: string;
undo(): void {
this.setPdfDocument(this.previousState);
}
}
// Rotate pages command
export class RotatePagesCommand extends PageCommand {
private pageIds: string[];
private rotation: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
rotation: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.rotation = rotation;
}
execute(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, rotation: page.rotation + this.rotation };
}
return page;
});
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
const direction = this.rotation > 0 ? 'right' : 'left';
return `Rotate ${this.pageIds.length} page(s) ${direction}`;
}
}
// Delete pages command
export class DeletePagesCommand extends PageCommand {
private pageIds: string[];
private deletedPages: PDFPage[];
private deletedPositions: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.deletedPages = [];
this.deletedPositions = new Map();
}
execute(): void {
// Store deleted pages and their positions for undo
this.deletedPages = this.pdfDocument.pages.filter(page =>
this.pageIds.includes(page.id)
);
this.deletedPages.forEach(page => {
const index = this.pdfDocument.pages.findIndex(p => p.id === page.id);
this.deletedPositions.set(page.id, index);
});
const updatedPages = this.pdfDocument.pages
.filter(page => !this.pageIds.includes(page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
// Simply restore to the previous state (before deletion)
this.setPdfDocument(this.previousState);
}
get description(): string {
return `Delete ${this.pageIds.length} page(s)`;
}
}
// Move pages command
export class MovePagesCommand extends PageCommand {
private pageIds: string[];
private targetIndex: number;
private originalIndices: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.targetIndex = targetIndex;
this.originalIndices = new Map();
}
execute(): void {
// Store original positions
this.pageIds.forEach(pageId => {
const index = this.pdfDocument.pages.findIndex(p => p.id === pageId);
this.originalIndices.set(pageId, index);
});
let newPages = [...this.pdfDocument.pages];
const pagesToMove = this.pageIds
.map(id => this.pdfDocument.pages.find(p => p.id === id))
.filter((page): page is PDFPage => page !== undefined);
// Remove pages to move
newPages = newPages.filter(page => !this.pageIds.includes(page.id));
// Insert pages at target position
newPages.splice(this.targetIndex, 0, ...pagesToMove);
// Update page numbers
newPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: newPages,
totalPages: newPages.length
});
}
get description(): string {
return `Move ${this.pageIds.length} page(s)`;
}
}
// Reorder single page command (for drag-and-drop)
export class ReorderPageCommand extends PageCommand {
private pageId: string;
private targetIndex: number;
private originalIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageId: string,
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageId = pageId;
this.targetIndex = targetIndex;
this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
}
execute(): void {
const newPages = [...this.pdfDocument.pages];
const [movedPage] = newPages.splice(this.originalIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
// Update page numbers
const updatedPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`;
}
}
// Toggle split markers command
export class ToggleSplitCommand extends PageCommand {
private pageIds: string[];
private previousSplitStates: Map<string, boolean>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.previousSplitStates = new Map();
}
execute(): void {
// Store previous split states
this.pageIds.forEach(pageId => {
const page = this.pdfDocument.pages.find(p => p.id === pageId);
if (page) {
this.previousSplitStates.set(pageId, !!page.splitBefore);
}
});
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, splitBefore: !page.splitBefore };
}
return page;
});
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
const previousState = this.previousSplitStates.get(page.id);
return { ...page, splitBefore: previousState };
}
return page;
});
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Toggle split markers for ${this.pageIds.length} page(s)`;
}
}
// Add pages command (for inserting new files)
export class AddPagesCommand extends PageCommand {
private newPages: PDFPage[];
private insertIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
newPages: PDFPage[],
insertIndex: number = -1 // -1 means append to end
) {
super(pdfDocument, setPdfDocument);
this.newPages = newPages;
this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex;
}
execute(): void {
const newPagesArray = [...this.pdfDocument.pages];
newPagesArray.splice(this.insertIndex, 0, ...this.newPages);
// Update page numbers for all pages
const updatedPages = newPagesArray.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
const updatedPages = this.pdfDocument.pages
.filter(page => !this.newPages.some(newPage => newPage.id === page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Add ${this.newPages.length} page(s)`;
}
}
// Command sequence for bulk operations
export class PageCommandSequence implements CommandSequence {
commands: Command[];
description: string;
constructor(commands: Command[], description?: string) {
this.commands = commands;
this.description = description || `Execute ${commands.length} operations`;
}
execute(): void {
this.commands.forEach(command => command.execute());
}
undo(): void {
// Undo in reverse order
[...this.commands].reverse().forEach(command => command.undo());
}
}

View File

@ -111,11 +111,15 @@ export default function Workbench() {
onRotate={pageEditorFunctions.handleRotate} onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete} onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit} onSplit={pageEditorFunctions.handleSplit}
onExportSelected={pageEditorFunctions.onExportSelected} onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportAll={pageEditorFunctions.onExportAll} onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading} exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode} selectionMode={pageEditorFunctions.selectionMode}
selectedPages={pageEditorFunctions.selectedPages} selectedPages={pageEditorFunctions.selectedPages}
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/> />
)} )}
</> </>

View File

@ -3,10 +3,11 @@ import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css'; import styles from './PageEditor.module.css';
import { GRID_CONSTANTS } from './constants';
interface DragDropItem { interface DragDropItem {
id: string; id: string;
splitBefore?: boolean; splitAfter?: boolean;
} }
interface DragDropGridProps<T extends DragDropItem> { interface DragDropGridProps<T extends DragDropItem> {
@ -33,10 +34,7 @@ const DragDropGrid = <T extends DragDropItem>({
// Responsive grid configuration // Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4); const [itemsPerRow, setItemsPerRow] = useState(4);
const ITEM_WIDTH = 320; // 20rem (page width) const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
const ITEM_GAP = 24; // 1.5rem gap between items
const ITEM_HEIGHT = 340; // 20rem + gap
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
// Calculate items per row based on container width // Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => { const calculateItemsPerRow = useCallback(() => {
@ -45,6 +43,11 @@ const DragDropGrid = <T extends DragDropItem>({
const containerWidth = containerRef.current.offsetWidth; const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4; // Container not measured yet if (containerWidth === 0) return 4; // Container not measured yet
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
// Calculate how many items fit: (width - gap) / (itemWidth + gap) // Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP; const itemWithGap = ITEM_WIDTH + ITEM_GAP;
@ -82,12 +85,21 @@ const DragDropGrid = <T extends DragDropItem>({
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow), count: Math.ceil(items.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element, getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
estimateSize: () => ITEM_HEIGHT, estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
},
overscan: OVERSCAN, overscan: OVERSCAN,
}); });
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
return ( return (
<Box <Box
ref={containerRef} ref={containerRef}
@ -102,6 +114,8 @@ const DragDropGrid = <T extends DragDropItem>({
height: `${rowVirtualizer.getTotalSize()}px`, height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%', width: '100%',
position: 'relative', position: 'relative',
margin: '0 auto',
maxWidth: `${gridWidth}px`,
}} }}
> >
{rowVirtualizer.getVirtualItems().map((virtualRow) => { {rowVirtualizer.getVirtualItems().map((virtualRow) => {
@ -124,18 +138,17 @@ const DragDropGrid = <T extends DragDropItem>({
<div <div
style={{ style={{
display: 'flex', display: 'flex',
gap: '1.5rem', gap: GRID_CONSTANTS.ITEM_GAP,
justifyContent: 'flex-start', justifyContent: 'flex-start',
height: '100%', height: '100%',
alignItems: 'center', alignItems: 'center',
position: 'relative'
}} }}
> >
{rowItems.map((item, itemIndex) => { {rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex; const actualIndex = startIndex + itemIndex;
return ( return (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
{/* Item */} {/* Item */}
{renderItem(item, actualIndex, itemRefs)} {renderItem(item, actualIndex, itemRefs)}
</React.Fragment> </React.Fragment>

View File

@ -16,7 +16,7 @@
} }
.pageContainer:hover .pageHoverControls { .pageContainer:hover .pageHoverControls {
opacity: 1 !important; opacity: 0.95 !important;
} }
/* Checkbox container - prevent transform inheritance */ /* Checkbox container - prevent transform inheritance */

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,10 @@ import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut"; import ContentCutIcon from "@mui/icons-material/ContentCut";
import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight"; import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import DownloadIcon from "@mui/icons-material/Download";
interface PageEditorControlsProps { interface PageEditorControlsProps {
// Close/Reset functions // Close/Reset functions
@ -23,27 +27,81 @@ interface PageEditorControlsProps {
onRotate: (direction: 'left' | 'right') => void; onRotate: (direction: 'left' | 'right') => void;
onDelete: () => void; onDelete: () => void;
onSplit: () => void; onSplit: () => void;
onSplitAll: () => void;
onPageBreak: () => void;
onPageBreakAll: () => void;
// Export functions // Export functions (moved to right rail)
onExportSelected: () => void;
onExportAll: () => void; onExportAll: () => void;
exportLoading: boolean; exportLoading: boolean;
// Selection state // Selection state
selectionMode: boolean; selectionMode: boolean;
selectedPages: number[]; selectedPages: number[];
// Split state (for tooltip logic)
splitPositions?: Set<number>;
totalPages?: number;
} }
const PageEditorControls = ({ const PageEditorControls = ({
onClosePdf,
onUndo, onUndo,
onRedo, onRedo,
canUndo, canUndo,
canRedo, canRedo,
onRotate, onRotate,
onDelete,
onSplit, onSplit,
onSplitAll,
onPageBreak,
onPageBreakAll,
onExportAll,
exportLoading,
selectionMode, selectionMode,
selectedPages selectedPages,
splitPositions,
totalPages
}: PageEditorControlsProps) => { }: PageEditorControlsProps) => {
// Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => {
if (!splitPositions || !totalPages || selectedPages.length === 0) {
return "Split Selected";
}
// Convert selected pages to split positions (same logic as handleSplit)
const selectedSplitPositions = selectedPages.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1);
if (selectedSplitPositions.length === 0) {
return "Split Selected";
}
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedSplitPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const willRemoveSplits = existingSplitsCount > noSplitsCount;
if (willRemoveSplits) {
return existingSplitsCount === selectedSplitPositions.length
? "Remove All Selected Splits"
: "Remove Selected Splits";
} else {
return existingSplitsCount === 0
? "Split Selected"
: "Complete Selected Splits";
}
};
// Calculate page break tooltip text
const getPageBreakTooltip = () => {
return selectedPages.length > 0
? `Insert ${selectedPages.length} Page Break${selectedPages.length > 1 ? 's' : ''}`
: "Insert Page Breaks";
};
return ( return (
<div <div
style={{ style={{
@ -72,7 +130,7 @@ const PageEditorControls = ({
border: '1px solid var(--border-default)', border: '1px solid var(--border-default)',
borderRadius: '16px 16px 0 0', borderRadius: '16px 16px 0 0',
pointerEvents: 'auto', pointerEvents: 'auto',
minWidth: 420, minWidth: 360,
maxWidth: 700, maxWidth: 700,
flexWrap: 'wrap', flexWrap: 'wrap',
justifyContent: 'center', justifyContent: 'center',
@ -83,12 +141,12 @@ const PageEditorControls = ({
{/* Undo/Redo */} {/* Undo/Redo */}
<Tooltip label="Undo"> <Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg"> <ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
<UndoIcon /> <UndoIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Redo"> <Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg"> <ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
<RedoIcon /> <RedoIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@ -96,40 +154,66 @@ const PageEditorControls = ({
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} /> <div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Page Operations */} {/* Page Operations */}
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}> <Tooltip label="Rotate Selected Left">
<ActionIcon <ActionIcon
onClick={() => onRotate('left')} onClick={() => onRotate('left')}
disabled={selectionMode && selectedPages.length === 0} disabled={selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} variant="subtle"
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg" size="lg"
> >
<RotateLeftIcon /> <RotateLeftIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}> <Tooltip label="Rotate Selected Right">
<ActionIcon <ActionIcon
onClick={() => onRotate('right')} onClick={() => onRotate('right')}
disabled={selectionMode && selectedPages.length === 0} disabled={selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} variant="subtle"
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg" size="lg"
> >
<RotateRightIcon /> <RotateRightIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}> <Tooltip label="Delete Selected">
<ActionIcon
onClick={onDelete}
disabled={selectedPages.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getSplitTooltip()}>
<ActionIcon <ActionIcon
onClick={onSplit} onClick={onSplit}
disabled={selectionMode && selectedPages.length === 0} disabled={selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} variant="subtle"
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg" size="lg"
> >
<ContentCutIcon /> <ContentCutIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={getPageBreakTooltip()}>
<ActionIcon
onClick={onPageBreak}
disabled={selectedPages.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<InsertPageBreakIcon />
</ActionIcon>
</Tooltip>
</div> </div>
</div> </div>
); );

View File

@ -1,43 +1,46 @@
import React, { useCallback, useState, useEffect, useRef } from 'react'; import React, { useCallback, useState, useEffect, useRef } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core'; import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft'; import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight'; import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut'; 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 { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '../../types/pageEditor'; import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo';
import { useFileState } from '../../contexts/FileContext';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration'; import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import styles from './PageEditor.module.css'; import styles from './PageEditor.module.css';
interface PageThumbnailProps { interface PageThumbnailProps {
page: PDFPage; page: PDFPage;
index: number; index: number;
totalPages: number; totalPages: number;
originalFile?: File; // For lazy thumbnail generation originalFile?: File;
selectedPages: number[]; selectedPages: number[];
selectionMode: boolean; selectionMode: boolean;
movingPage: number | null; movingPage: number | null;
isAnimating: boolean; isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>; pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onTogglePage: (pageNumber: number) => void;
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
onExecuteCommand: (command: Command) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (pageNumber: number | null) => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
RotatePagesCommand: typeof RotatePagesCommand; onTogglePage: (pageNumber: number) => void;
DeletePagesCommand: typeof DeletePagesCommand; onAnimateReorder: () => void;
ToggleSplitCommand: typeof ToggleSplitCommand; onExecuteCommand: (command: { execute: () => void }) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (page: number | null) => void;
onDeletePage: (pageNumber: number) => void;
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
createSplitCommand: (position: number) => { execute: () => void };
pdfDocument: PDFDocument; pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void; setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<number>;
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
} }
const PageThumbnail = React.memo(({ const PageThumbnail: React.FC<PageThumbnailProps> = ({
page, page,
index, index,
totalPages, totalPages,
@ -47,83 +50,97 @@ const PageThumbnail = React.memo(({
movingPage, movingPage,
isAnimating, isAnimating,
pageRefs, pageRefs,
onReorderPages,
onTogglePage, onTogglePage,
onAnimateReorder, onAnimateReorder,
onExecuteCommand, onExecuteCommand,
onSetStatus, onSetStatus,
onSetMovingPage, onSetMovingPage,
onReorderPages, onDeletePage,
RotatePagesCommand, createRotateCommand,
DeletePagesCommand, createDeleteCommand,
ToggleSplitCommand, createSplitCommand,
pdfDocument, pdfDocument,
setPdfDocument, setPdfDocument,
splitPositions,
onInsertFiles,
}: PageThumbnailProps) => { }: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const dragElementRef = useRef<HTMLDivElement>(null); const dragElementRef = useRef<HTMLDivElement>(null);
const { state, selectors } = useFileState(); const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
const { openFilesModal } = useFilesModalContext();
// Update thumbnail URL when page prop changes - prevent redundant updates // Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio
const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail);
if (firstRealPage?.thumbnail) {
// Try to get aspect ratio from an actual thumbnail image
// For now, default to A4 but could be enhanced to measure image dimensions
return '1 / 1.414'; // A4 ratio as fallback
}
return '1 / 1.414'; // Default A4 ratio
}, [pdfDocument.pages]);
// Update thumbnail URL when page prop changes
useEffect(() => { useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) { if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
setThumbnailUrl(page.thumbnail); setThumbnailUrl(page.thumbnail);
} }
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles }, [page.thumbnail, thumbnailUrl]);
// Request thumbnail generation if not available (optimized for performance) // Request thumbnail if missing (on-demand, virtualized approach)
useEffect(() => { useEffect(() => {
if (thumbnailUrl || !originalFile) { let isCancelled = false;
return; // Skip if we already have a thumbnail or no original file
// If we already have a thumbnail, use it
if (page.thumbnail) {
setThumbnailUrl(page.thumbnail);
return;
} }
// Check cache first without async call // Check cache first
const cachedThumbnail = getThumbnailFromCache(page.id); const cachedThumbnail = getThumbnailFromCache(page.id);
if (cachedThumbnail) { if (cachedThumbnail) {
setThumbnailUrl(cachedThumbnail); setThumbnailUrl(cachedThumbnail);
return; return;
} }
let cancelled = false; // Request thumbnail generation if we have the original file
if (originalFile) {
const pageNumber = page.originalPageNumber;
const loadThumbnail = async () => { requestThumbnail(page.id, originalFile, pageNumber)
try { .then(thumbnail => {
const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber); if (!isCancelled && thumbnail) {
setThumbnailUrl(thumbnail);
// Only update if component is still mounted and we got a result }
if (!cancelled && thumbnail) { })
setThumbnailUrl(thumbnail); .catch(error => {
} console.warn(`Failed to generate thumbnail for ${page.id}:`, error);
} catch (error) { });
if (!cancelled) { }
console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error);
}
}
};
loadThumbnail();
// Cleanup function to prevent state updates after unmount
return () => { return () => {
cancelled = true; isCancelled = true;
}; };
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops }, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
const pageElementRef = useCallback((element: HTMLDivElement | null) => { const pageElementRef = useCallback((element: HTMLDivElement | null) => {
if (element) { if (element) {
pageRefs.current.set(page.id, element); pageRefs.current.set(page.id, element);
dragElementRef.current = element; dragElementRef.current = element;
const dragCleanup = draggable({ const dragCleanup = draggable({
element, element,
getInitialData: () => ({ getInitialData: () => ({
pageNumber: page.pageNumber, pageNumber: page.pageNumber,
pageId: page.id, pageId: page.id,
selectedPages: selectionMode && selectedPages.includes(page.pageNumber) selectedPages: selectionMode && selectedPages.includes(page.pageNumber)
? selectedPages ? selectedPages
: [page.pageNumber] : [page.pageNumber]
}), }),
onDragStart: () => { onDragStart: () => {
@ -131,14 +148,14 @@ const PageThumbnail = React.memo(({
}, },
onDrop: ({ location }) => { onDrop: ({ location }) => {
setIsDragging(false); setIsDragging(false);
if (location.current.dropTargets.length === 0) { if (location.current.dropTargets.length === 0) {
return; return;
} }
const dropTarget = location.current.dropTargets[0]; const dropTarget = location.current.dropTargets[0];
const targetData = dropTarget.data; const targetData = dropTarget.data;
if (targetData.type === 'page') { if (targetData.type === 'page') {
const targetPageNumber = targetData.pageNumber as number; const targetPageNumber = targetData.pageNumber as number;
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
@ -146,6 +163,8 @@ const PageThumbnail = React.memo(({
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber) const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
? selectedPages ? selectedPages
: undefined; : undefined;
// Trigger animation for drag & drop
onAnimateReorder();
onReorderPages(page.pageNumber, targetIndex, pagesToMove); onReorderPages(page.pageNumber, targetIndex, pagesToMove);
} }
} }
@ -153,8 +172,7 @@ const PageThumbnail = React.memo(({
}); });
element.style.cursor = 'grab'; element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({ const dropCleanup = dropTargetForElements({
element, element,
getData: () => ({ getData: () => ({
@ -163,7 +181,7 @@ const PageThumbnail = React.memo(({
}), }),
onDrop: ({ source }) => {} onDrop: ({ source }) => {}
}); });
(element as any).__dragCleanup = () => { (element as any).__dragCleanup = () => {
dragCleanup(); dragCleanup();
dropCleanup(); dropCleanup();
@ -176,15 +194,103 @@ const PageThumbnail = React.memo(({
} }
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]); }, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
// DOM command handlers
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], -90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleRotateRight = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], 90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleDelete = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onDeletePage(page.pageNumber);
onSetStatus(`Deleted page ${page.pageNumber}`);
}, [page.pageNumber, onDeletePage, onSetStatus]);
const handleSplit = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Create a command to toggle split at this position
const command = createSplitCommand(index);
onExecuteCommand(command);
const hasSplit = splitPositions.has(index);
const action = hasSplit ? 'removed' : 'added';
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);
setMouseStartPos({ x: e.clientX, y: e.clientY });
}, []);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (!isMouseDown || !mouseStartPos) {
setIsMouseDown(false);
setMouseStartPos(null);
return;
}
// Calculate distance moved
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// If mouse moved less than 5 pixels, consider it a click (not a drag)
if (distance < 5 && !isDragging) {
onTogglePage(page.pageNumber);
}
setIsMouseDown(false);
setMouseStartPos(null);
}, [isMouseDown, mouseStartPos, isDragging, page.pageNumber, onTogglePage]);
const handleMouseLeave = useCallback(() => {
setIsMouseDown(false);
setMouseStartPos(null);
}, []);
return ( return (
<div <div
ref={pageElementRef} ref={pageElementRef}
data-page-id={page.id}
data-page-number={page.pageNumber} data-page-number={page.pageNumber}
className={` className={`
${styles.pageContainer} ${styles.pageContainer}
!rounded-lg !rounded-lg
cursor-grab ${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
select-none select-none
w-[20rem] w-[20rem]
h-[20rem] h-[20rem]
@ -204,6 +310,9 @@ const PageThumbnail = React.memo(({
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}} }}
draggable={false} draggable={false}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
> >
{ {
<div <div
@ -217,25 +326,25 @@ const PageThumbnail = React.memo(({
borderRadius: '4px', borderRadius: '4px',
padding: '2px', padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)', boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto', pointerEvents: 'auto'
cursor: 'pointer'
}} }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => {
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
onMouseUp={(e) => e.stopPropagation()}
onDragStart={(e) => { onDragStart={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
onClick={(e) => {
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
> >
<Checkbox <Checkbox
checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false} checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false}
onChange={() => { onChange={() => {
// onChange is handled by the parent div click // Selection is handled by container mouseDown
}} }}
size="sm" size="sm"
style={{ pointerEvents: 'none' }}
/> />
</div> </div>
} }
@ -254,7 +363,23 @@ const PageThumbnail = React.memo(({
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
{thumbnailUrl ? ( {page.isBlankPage ? (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: '70%',
aspectRatio: getDocumentAspectRatio(),
backgroundColor: 'white',
border: '1px solid #e9ecef',
borderRadius: 2
}}></div>
</div>
) : thumbnailUrl ? (
<img <img
src={thumbnailUrl} src={thumbnailUrl}
alt={`Page ${page.pageNumber}`} alt={`Page ${page.pageNumber}`}
@ -280,12 +405,12 @@ const PageThumbnail = React.memo(({
className={styles.pageNumber} className={styles.pageNumber}
size="sm" size="sm"
fw={500} fw={500}
c="white"
style={{ style={{
color: 'white', // Keep white for page numbers as they have colored backgrounds
position: 'absolute', position: 'absolute',
top: 5, top: 5,
left: 5, left: 5,
background: 'rgba(162, 201, 255, 0.8)', background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px', padding: '6px 8px',
borderRadius: 8, borderRadius: 8,
zIndex: 2, zIndex: 2,
@ -303,7 +428,8 @@ const PageThumbnail = React.memo(({
bottom: 8, bottom: 8,
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
padding: '6px 12px', padding: '6px 12px',
borderRadius: 20, borderRadius: 20,
opacity: 0, opacity: 0,
@ -314,19 +440,25 @@ const PageThumbnail = React.memo(({
alignItems: 'center', alignItems: 'center',
whiteSpace: 'nowrap' whiteSpace: 'nowrap'
}} }}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
> >
<Tooltip label="Move Left"> <Tooltip label="Move Left">
<ActionIcon <ActionIcon
size="md" size="md"
variant="subtle" variant="subtle"
c="white" style={{ color: 'var(--mantine-color-dimmed)' }}
disabled={index === 0} disabled={index === 0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) { if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber); onSetMovingPage(page.pageNumber);
onAnimateReorder(page.pageNumber, index - 1); // Trigger animation
setTimeout(() => onSetMovingPage(null), 500); onAnimateReorder();
// Actually move the page left (swap with previous page)
onReorderPages(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`); onSetStatus(`Moved page ${page.pageNumber} left`);
} }
}} }}
@ -339,14 +471,17 @@ const PageThumbnail = React.memo(({
<ActionIcon <ActionIcon
size="md" size="md"
variant="subtle" variant="subtle"
c="white" style={{ color: 'var(--mantine-color-dimmed)' }}
disabled={index === totalPages - 1} disabled={index === totalPages - 1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) { if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber); onSetMovingPage(page.pageNumber);
onAnimateReorder(page.pageNumber, index + 1); // Trigger animation
setTimeout(() => onSetMovingPage(null), 500); onAnimateReorder();
// Actually move the page right (swap with next page)
onReorderPages(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`); onSetStatus(`Moved page ${page.pageNumber} right`);
} }
}} }}
@ -359,18 +494,8 @@ const PageThumbnail = React.memo(({
<ActionIcon <ActionIcon
size="md" size="md"
variant="subtle" variant="subtle"
c="white" style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={(e) => { onClick={handleRotateLeft}
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
-90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}}
> >
<RotateLeftIcon style={{ fontSize: 20 }} /> <RotateLeftIcon style={{ fontSize: 20 }} />
</ActionIcon> </ActionIcon>
@ -380,18 +505,8 @@ const PageThumbnail = React.memo(({
<ActionIcon <ActionIcon
size="md" size="md"
variant="subtle" variant="subtle"
c="white" style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={(e) => { onClick={handleRotateRight}
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}}
> >
<RotateRightIcon style={{ fontSize: 20 }} /> <RotateRightIcon style={{ fontSize: 20 }} />
</ActionIcon> </ActionIcon>
@ -402,66 +517,41 @@ const PageThumbnail = React.memo(({
size="md" size="md"
variant="subtle" variant="subtle"
c="red" c="red"
onClick={(e) => { onClick={handleDelete}
e.stopPropagation();
const command = new DeletePagesCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Deleted page ${page.pageNumber}`);
}}
> >
<DeleteIcon style={{ fontSize: 20 }} /> <DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
{index > 0 && ( {index < totalPages - 1 && (
<Tooltip label="Split Here"> <Tooltip label="Split After">
<ActionIcon <ActionIcon
size="md" size="md"
variant="subtle" variant="subtle"
c="white" style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={(e) => { onClick={handleSplit}
e.stopPropagation();
const command = new ToggleSplitCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
}}
> >
<ContentCutIcon style={{ fontSize: 20 }} /> <ContentCutIcon style={{ fontSize: 20 }} />
</ActionIcon> </ActionIcon>
</Tooltip> </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>
</div> </div>
</div> </div>
); );
}, (prevProps, nextProps) => { };
// Helper for shallow array comparison
const arraysEqual = (a: number[], b: number[]) => {
return a.length === b.length && a.every((val, i) => val === b[i]);
};
// Only re-render if essential props change
return (
prevProps.page.id === nextProps.page.id &&
prevProps.page.pageNumber === nextProps.page.pageNumber &&
prevProps.page.rotation === nextProps.page.rotation &&
prevProps.page.thumbnail === nextProps.page.thumbnail &&
// Shallow compare selectedPages array for better stability
(prevProps.selectedPages === nextProps.selectedPages ||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
prevProps.selectionMode === nextProps.selectionMode &&
prevProps.movingPage === nextProps.movingPage &&
prevProps.isAnimating === nextProps.isAnimating
);
});
export default PageThumbnail; export default PageThumbnail;

View File

@ -0,0 +1,892 @@
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,
private setSelectedPages: (pages: number[]) => 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);
// Maintain existing selection by mapping original selected pages to their new positions
const updatedSelection: number[] = [];
this.selectedPageNumbers.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 ${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<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[] = [];
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?.();
}
}

View File

@ -0,0 +1,8 @@
// Shared constants for PageEditor grid layout
export const GRID_CONSTANTS = {
ITEM_WIDTH: '20rem', // page width
ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap
ITEM_GAP: '1.5rem', // gap between items
OVERSCAN_SMALL: 4, // Overscan for normal documents
OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages)
} as const;

View File

@ -0,0 +1,176 @@
import { useMemo } from 'react';
import { useFileState } from '../../../contexts/FileContext';
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
export interface PageDocumentHook {
document: PDFDocument | null;
isVeryLargeDocument: boolean;
isLoading: boolean;
}
/**
* Hook for managing PDF document state and metadata in PageEditor
* Handles document merging, large document detection, and loading states
*/
export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
const processedFilePages = primaryFileRecord?.processedFile?.pages;
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => {
if (activeFileIds.length === 0) return null;
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryFileRecord) {
console.log('🎬 PageEditor: No primary file record found, showing loading');
return null;
}
const name =
activeFileIds.length === 1
? (primaryFileRecord.name ?? 'document.pdf')
: activeFileIds
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
// Build page insertion map from files with insertion positions
const insertionMap = new Map<string, string[]>(); // insertAfterPageId -> fileIds
const originalFileIds: string[] = [];
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;
// Helper function to create pages from a file
const createPagesFromFile = (fileId: string, startPageNumber: number): PDFPage[] => {
const fileRecord = selectors.getFileRecord(fileId);
if (!fileRecord) {
return [];
}
const processedFile = fileRecord.processedFile;
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: startPageNumber + pageIndex,
thumbnail: page.thumbnail || null,
rotation: page.rotation || 0,
selected: false,
splitAfter: page.splitAfter || false,
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
originalFileId: fileId,
}));
} else if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count
filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
id: `${fileId}-${pageIndex + 1}`,
pageNumber: startPageNumber + pageIndex,
originalPageNumber: pageIndex + 1,
originalFileId: fileId,
rotation: 0,
thumbnail: null,
selected: false,
splitAfter: false,
}));
}
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) {
return null;
}
const mergedDoc: PDFDocument = {
id: activeFileIds.join('-'),
name,
file: primaryFile!,
pages,
totalPages: pages.length,
};
return mergedDoc;
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {
return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false;
}, [mergedPdfDocument?.totalPages]);
// Loading state
const isLoading = globalProcessing && !mergedPdfDocument;
return {
document: mergedPdfDocument,
isVeryLargeDocument,
isLoading
};
}

View File

@ -0,0 +1,96 @@
import { useState, useCallback } from 'react';
export interface PageEditorState {
// Selection state
selectionMode: boolean;
selectedPageNumbers: number[];
// Animation state
movingPage: number | null;
isAnimating: boolean;
// Split state
splitPositions: Set<number>;
// Export state
exportLoading: boolean;
// Actions
setSelectionMode: (mode: boolean) => void;
setSelectedPageNumbers: (pages: number[]) => void;
setMovingPage: (pageNumber: number | null) => void;
setIsAnimating: (animating: boolean) => void;
setSplitPositions: (positions: Set<number>) => void;
setExportLoading: (loading: boolean) => void;
// Helper functions
togglePage: (pageNumber: number) => void;
toggleSelectAll: (totalPages: number) => void;
animateReorder: () => void;
}
/**
* Hook for managing PageEditor UI state
* Handles selection, animation, splits, and export states
*/
export function usePageEditorState(): PageEditorState {
// Selection state
const [selectionMode, setSelectionMode] = useState(false);
const [selectedPageNumbers, setSelectedPageNumbers] = useState<number[]>([]);
// Animation state
const [movingPage, setMovingPage] = useState<number | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
// Split state - position-based split tracking (replaces page-based splitAfter)
const [splitPositions, setSplitPositions] = useState<Set<number>>(new Set());
// Export state
const [exportLoading, setExportLoading] = useState(false);
// Helper functions
const togglePage = useCallback((pageNumber: number) => {
setSelectedPageNumbers(prev =>
prev.includes(pageNumber)
? prev.filter(n => n !== pageNumber)
: [...prev, pageNumber]
);
}, []);
const toggleSelectAll = useCallback((totalPages: number) => {
if (!totalPages) return;
const allPageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
setSelectedPageNumbers(prev =>
prev.length === allPageNumbers.length ? [] : allPageNumbers
);
}, []);
const animateReorder = useCallback(() => {
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 500);
}, []);
return {
// State
selectionMode,
selectedPageNumbers,
movingPage,
isAnimating,
splitPositions,
exportLoading,
// Setters
setSelectionMode,
setSelectedPageNumbers,
setMovingPage,
setIsAnimating,
setSplitPositions,
setExportLoading,
// Helpers
togglePage,
toggleSelectAll,
animateReorder,
};
}

View File

@ -45,19 +45,14 @@ export default function RightRail() {
} }
if (currentView === 'pageEditor') { if (currentView === 'pageEditor') {
let totalItems = 0; // Use PageEditor's own state
fileRecords.forEach(rec => { const totalItems = pageEditorFunctions?.totalPages || 0;
const pf = rec.processedFile; const selectedCount = pageEditorFunctions?.selectedPages?.length || 0;
if (pf) {
totalItems += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0;
return { totalItems, selectedCount }; return { totalItems, selectedCount };
} }
return { totalItems: 0, selectedCount: 0 }; return { totalItems: 0, selectedCount: 0 };
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]); }, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]);
const { totalItems, selectedCount } = getSelectionState(); const { totalItems, selectedCount } = getSelectionState();
@ -70,19 +65,10 @@ export default function RightRail() {
} }
if (currentView === 'pageEditor') { if (currentView === 'pageEditor') {
let totalPages = 0; // Use PageEditor's select all function
fileRecords.forEach(rec => { pageEditorFunctions?.handleSelectAll?.();
const pf = rec.processedFile;
if (pf) {
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
if (totalPages > 0) {
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
}
} }
}, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]); }, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]);
const handleDeselectAll = useCallback(() => { const handleDeselectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') { if (currentView === 'fileEditor' || currentView === 'viewer') {
@ -90,9 +76,10 @@ export default function RightRail() {
return; return;
} }
if (currentView === 'pageEditor') { if (currentView === 'pageEditor') {
setSelectedPages([]); // Use PageEditor's deselect all function
pageEditorFunctions?.handleDeselectAll?.();
} }
}, [currentView, setSelectedFiles, setSelectedPages]); }, [currentView, setSelectedFiles, pageEditorFunctions]);
const handleExportAll = useCallback(() => { const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') { if (currentView === 'fileEditor' || currentView === 'viewer') {
@ -151,24 +138,21 @@ export default function RightRail() {
const updatePagesFromCSV = useCallback(() => { const updatePagesFromCSV = useCallback(() => {
const rawPages = parseCSVInput(csvInput); const rawPages = parseCSVInput(csvInput);
// Determine max page count from processed records // Use PageEditor's total pages for validation
const maxPages = fileRecords.reduce((sum, rec) => { const maxPages = pageEditorFunctions?.totalPages || 0;
const pf = rec.processedFile;
if (!pf) return sum;
return sum + ((pf.totalPages as number) || (pf.pages?.length || 0));
}, 0);
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b); const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
setSelectedPages(normalized); // Use PageEditor's function to set selected pages
}, [csvInput, parseCSVInput, fileRecords, setSelectedPages]); pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, parseCSVInput, pageEditorFunctions]);
// Sync csvInput with selectedPageNumbers changes // Sync csvInput with PageEditor's selected pages
useEffect(() => { useEffect(() => {
const sortedPageNumbers = Array.isArray(selectedPageNumbers) const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPages)
? [...selectedPageNumbers].sort((a, b) => a - b) ? [...pageEditorFunctions.selectedPages].sort((a, b) => a - b)
: []; : [];
const newCsvInput = sortedPageNumbers.join(', '); const newCsvInput = sortedPageNumbers.join(', ');
setCsvInput(newCsvInput); setCsvInput(newCsvInput);
}, [selectedPageNumbers]); }, [pageEditorFunctions?.selectedPages]);
// Clear CSV input when files change (use stable signature to avoid ref churn) // Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => { useEffect(() => {
@ -278,7 +262,7 @@ export default function RightRail() {
<BulkSelectionPanel <BulkSelectionPanel
csvInput={csvInput} csvInput={csvInput}
setCsvInput={setCsvInput} setCsvInput={setCsvInput}
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []} selectedPages={Array.isArray(pageEditorFunctions?.selectedPages) ? pageEditorFunctions.selectedPages : []}
onUpdatePagesFromCSV={updatePagesFromCSV} onUpdatePagesFromCSV={updatePagesFromCSV}
/> />
</div> </div>
@ -299,8 +283,8 @@ export default function RightRail() {
variant="subtle" variant="subtle"
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }} onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)} disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPages?.length || 0) === 0}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'} aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
> >
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" /> <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
@ -311,6 +295,26 @@ export default function RightRail() {
)} )}
{/* Export Selected Pages - page editor only */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPages?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</div>
</Tooltip>
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */} {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow> <Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
<div> <div>

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { FileMetadata } from '../types/file';
interface FilesModalContextType { interface FilesModalContextType {
isFilesModalOpen: boolean; isFilesModalOpen: boolean;
openFilesModal: () => void; openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
closeFilesModal: () => void; closeFilesModal: () => void;
onFileSelect: (file: File) => void; onFileSelect: (file: File) => void;
onFilesSelect: (files: 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 { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); 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); setIsFilesModalOpen(true);
}, []); }, []);
const closeFilesModal = useCallback(() => { const closeFilesModal = useCallback(() => {
setIsFilesModalOpen(false); setIsFilesModalOpen(false);
setInsertAfterPage(undefined); // Clear insertion position
setCustomHandler(undefined); // Clear custom handler
onModalClose?.(); onModalClose?.();
}, [onModalClose]); }, [onModalClose]);
const handleFileSelect = useCallback((file: File) => { 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(); closeFilesModal();
}, [addToActiveFiles, closeFilesModal]); }, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleFilesSelect = useCallback((files: File[]) => { 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(); closeFilesModal();
}, [addMultipleFiles, closeFilesModal]); }, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => { 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(); closeFilesModal();
}, [addStoredFiles, closeFilesModal]); }, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);
const setModalCloseCallback = useCallback((callback: () => void) => { const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback); setOnModalClose(() => callback);

View File

@ -84,6 +84,9 @@ interface AddFileOptions {
// For 'stored' files // For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>; 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 // Create initial processedFile metadata with page count
if (pageCount > 0) { if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail); record.processedFile = createProcessedFile(pageCount, thumbnail);

View File

@ -77,6 +77,7 @@ export function usePDFProcessor() {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${file.name}-page-${i}`,
pageNumber: i, pageNumber: i,
originalPageNumber: i,
thumbnail: null, // Will be loaded lazily thumbnail: null, // Will be loaded lazily
rotation: 0, rotation: 0,
selected: false selected: false

View File

@ -0,0 +1,178 @@
import { PDFDocument, PDFPage } from '../types/pageEditor';
/**
* Service for applying DOM changes to PDF document state
* Reads current DOM state and updates the document accordingly
*/
export class DocumentManipulationService {
/**
* Apply all DOM changes (rotations, splits, reordering) to document state
* Returns single document or multiple documents if splits are present
*/
applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set<number>): PDFDocument | PDFDocument[] {
console.log('DocumentManipulationService: Applying DOM changes to document');
console.log('Original document page order:', pdfDocument.pages.map(p => p.pageNumber));
console.log('Current display order:', currentDisplayOrder?.pages.map(p => p.pageNumber) || 'none provided');
console.log('Split positions:', splitPositions ? Array.from(splitPositions).sort() : 'none');
// Use current display order (from React state) if provided, otherwise use original order
const baseDocument = currentDisplayOrder || pdfDocument;
console.log('Using page order:', baseDocument.pages.map(p => p.pageNumber));
// Apply DOM changes to each page (rotation only now, splits are position-based)
let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page));
// Convert position-based splits to page-based splits for export
if (splitPositions && splitPositions.size > 0) {
updatedPages = updatedPages.map((page, index) => ({
...page,
splitAfter: splitPositions.has(index)
}));
}
// Create final document with reordered pages and applied changes
const finalDocument = {
...pdfDocument, // Use original document metadata but updated pages
pages: updatedPages // Use reordered pages with applied changes
};
// Check for splits and return multiple documents if needed
if (splitPositions && splitPositions.size > 0) {
return this.createSplitDocuments(finalDocument);
}
return finalDocument;
}
/**
* Check if document has split markers
*/
private hasSplitMarkers(document: PDFDocument): boolean {
return document.pages.some(page => page.splitAfter);
}
/**
* Create multiple documents from split markers
*/
private createSplitDocuments(document: PDFDocument): PDFDocument[] {
const documents: PDFDocument[] = [];
const splitPoints: number[] = [];
// Find split points - pages with splitAfter create split points AFTER them
document.pages.forEach((page, index) => {
if (page.splitAfter) {
console.log(`Found split marker at page ${page.pageNumber} (index ${index}), adding split point at ${index + 1}`);
splitPoints.push(index + 1);
}
});
// Add end point if not already there
if (splitPoints.length === 0 || splitPoints[splitPoints.length - 1] !== document.pages.length) {
splitPoints.push(document.pages.length);
}
console.log('Final split points:', splitPoints);
console.log('Total pages to split:', document.pages.length);
let startIndex = 0;
let partNumber = 1;
for (const endIndex of splitPoints) {
const segmentPages = document.pages.slice(startIndex, endIndex);
console.log(`Creating split document ${partNumber}: pages ${startIndex}-${endIndex-1} (${segmentPages.length} pages)`);
console.log(`Split document ${partNumber} page numbers:`, segmentPages.map(p => p.pageNumber));
if (segmentPages.length > 0) {
documents.push({
...document,
id: `${document.id}_part_${partNumber}`,
name: `${document.name.replace(/\.pdf$/i, '')}_part_${partNumber}.pdf`,
pages: segmentPages,
totalPages: segmentPages.length
});
partNumber++;
}
startIndex = endIndex;
}
console.log(`Created ${documents.length} split documents`);
return documents;
}
/**
* Apply DOM changes for a single page
*/
private applyPageChanges(page: PDFPage): PDFPage {
// Find the DOM element for this page
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (!pageElement) {
console.log(`Page ${page.pageNumber}: No DOM element found, keeping original state`);
return page;
}
const updatedPage = { ...page };
// Apply rotation changes from DOM
updatedPage.rotation = this.getRotationFromDOM(pageElement, page);
// Apply split marker changes from document state (already handled by commands)
// Split markers are already updated by ToggleSplitCommand, so no DOM reading needed
return updatedPage;
}
/**
* Read rotation from DOM element
*/
private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number {
const img = pageElement.querySelector('img');
if (img && img.style.transform) {
// Parse rotation from transform property (e.g., "rotate(90deg)" -> 90)
const rotationMatch = img.style.transform.match(/rotate\((-?\d+)deg\)/);
const domRotation = rotationMatch ? parseInt(rotationMatch[1]) : 0;
console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`);
return domRotation;
}
console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`);
return originalPage.rotation;
}
/**
* Reset all DOM changes (useful for "discard changes" functionality)
*/
resetDOMToDocumentState(pdfDocument: PDFDocument): void {
console.log('DocumentManipulationService: Resetting DOM to match document state');
pdfDocument.pages.forEach(page => {
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Reset rotation to match document state
img.style.transform = `rotate(${page.rotation}deg)`;
}
}
});
}
/**
* Check if DOM state differs from document state
*/
hasUnsavedChanges(pdfDocument: PDFDocument): boolean {
return pdfDocument.pages.some(page => {
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (pageElement) {
const domRotation = this.getRotationFromDOM(pageElement, page);
return domRotation !== page.rotation;
}
return false;
});
}
}
// Export singleton instance
export const documentManipulationService = new DocumentManipulationService();

View File

@ -4,20 +4,18 @@ import { PDFDocument, PDFPage } from '../types/pageEditor';
export interface ExportOptions { export interface ExportOptions {
selectedOnly?: boolean; selectedOnly?: boolean;
filename?: string; filename?: string;
splitDocuments?: boolean;
appendSuffix?: boolean; // when false, do not append _edited/_selected
} }
export class PDFExportService { export class PDFExportService {
/** /**
* Export PDF document with applied operations * Export PDF document with applied operations (single file source)
*/ */
async exportPDF( async exportPDF(
pdfDocument: PDFDocument, pdfDocument: PDFDocument,
selectedPageIds: string[] = [], selectedPageIds: string[] = [],
options: ExportOptions = {} options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> { ): Promise<{ blob: Blob; filename: string }> {
const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options; const { selectedOnly = false, filename } = options;
try { try {
// Determine which pages to export // Determine which pages to export
@ -29,17 +27,13 @@ export class PDFExportService {
throw new Error('No pages to export'); throw new Error('No pages to export');
} }
// Load original PDF once // Load original PDF and create new document
const originalPDFBytes = await pdfDocument.file.arrayBuffer(); const originalPDFBytes = await pdfDocument.file.arrayBuffer();
const sourceDoc = await PDFLibDocument.load(originalPDFBytes); const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false);
if (splitDocuments) { return { blob, filename: exportFilename };
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
} else {
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix);
return { blob, filename: exportFilename };
}
} catch (error) { } catch (error) {
console.error('PDF export error:', error); console.error('PDF export error:', error);
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
@ -47,28 +41,85 @@ export class PDFExportService {
} }
/** /**
* Create a single PDF document with all operations applied * Export PDF document with applied operations (multi-file source)
*/ */
private async createSingleDocument( async exportPDFMultiFile(
sourceDoc: PDFLibDocument, pdfDocument: PDFDocument,
sourceFiles: Map<string, File>,
selectedPageIds: string[] = [],
options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string }> {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
const pagesToExport = selectedOnly && selectedPageIds.length > 0
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
if (pagesToExport.length === 0) {
throw new Error('No pages to export');
}
const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false);
return { blob, filename: exportFilename };
} catch (error) {
console.error('Multi-file PDF export error:', error);
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Create a PDF document from multiple source files
*/
private async createMultiSourceDocument(
sourceFiles: Map<string, File>,
pages: PDFPage[] pages: PDFPage[]
): Promise<Blob> { ): Promise<Blob> {
const newDoc = await PDFLibDocument.create(); const newDoc = await PDFLibDocument.create();
// Load all source documents once and cache them
const loadedDocs = new Map<string, PDFLibDocument>();
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
const doc = await PDFLibDocument.load(arrayBuffer);
loadedDocs.set(fileId, doc);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
}
}
for (const page of pages) { for (const page of pages) {
// Get the original page from source document if (page.isBlankPage || page.originalPageNumber === -1) {
const sourcePageIndex = this.getOriginalSourceIndex(page); // Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { // Apply rotation if needed
// Copy the page
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
// Apply rotation
if (page.rotation !== 0) { if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation)); blankPage.setRotation(degrees(page.rotation));
} }
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
// Get the correct source document for this page
const sourceDoc = loadedDocs.get(page.originalFileId)!;
const sourcePageIndex = page.originalPageNumber - 1;
newDoc.addPage(copiedPage); if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page from the correct source document
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
// Apply rotation
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
newDoc.addPage(copiedPage);
}
} else {
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
} }
} }
@ -83,103 +134,60 @@ export class PDFExportService {
} }
/** /**
* Create multiple PDF documents based on split markers * Create a single PDF document with all operations applied (single source)
*/ */
private async createSplitDocuments( private async createSingleDocument(
sourceDoc: PDFLibDocument, sourceDoc: PDFLibDocument,
pages: PDFPage[], pages: PDFPage[]
baseFilename: string ): Promise<Blob> {
): Promise<{ blobs: Blob[]; filenames: string[] }> { const newDoc = await PDFLibDocument.create();
const splitPoints: number[] = [];
const blobs: Blob[] = [];
const filenames: string[] = [];
// Find split points for (const page of pages) {
pages.forEach((page, index) => { if (page.isBlankPage || page.originalPageNumber === -1) {
if (page.splitBefore && index > 0) { // Create a blank page
splitPoints.push(index); const blankPage = newDoc.addPage(PageSizes.A4);
}
});
// Add end point // Apply rotation if needed
splitPoints.push(pages.length); if (page.rotation !== 0) {
blankPage.setRotation(degrees(page.rotation));
let startIndex = 0;
let partNumber = 1;
for (const endIndex of splitPoints) {
const segmentPages = pages.slice(startIndex, endIndex);
if (segmentPages.length > 0) {
const newDoc = await PDFLibDocument.create();
for (const page of segmentPages) {
const sourcePageIndex = this.getOriginalSourceIndex(page);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
newDoc.addPage(copiedPage);
}
} }
} else {
// Get the original page from source document using originalPageNumber
const sourcePageIndex = page.originalPageNumber - 1;
// Set metadata if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
newDoc.setCreator('Stirling PDF'); // Copy the page
newDoc.setProducer('Stirling PDF'); const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
newDoc.setTitle(`${baseFilename} - Part ${partNumber}`);
const pdfBytes = await newDoc.save(); // Apply rotation
const blob = new Blob([pdfBytes], { type: 'application/pdf' }); if (page.rotation !== 0) {
const filename = this.generateSplitFilename(baseFilename, partNumber); copiedPage.setRotation(degrees(page.rotation));
}
blobs.push(blob); newDoc.addPage(copiedPage);
filenames.push(filename); }
partNumber++;
}
startIndex = endIndex;
}
return { blobs, filenames };
}
/**
* Derive the original page index from a page's stable id.
* Falls back to the current pageNumber if parsing fails.
*/
private getOriginalSourceIndex(page: PDFPage): number {
const match = page.id.match(/-page-(\d+)$/);
if (match) {
const originalNumber = parseInt(match[1], 10);
if (!Number.isNaN(originalNumber)) {
return originalNumber - 1; // zero-based index for pdf-lib
} }
} }
// Fallback to the visible page number
return Math.max(0, page.pageNumber - 1); // Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setCreationDate(new Date());
newDoc.setModificationDate(new Date());
const pdfBytes = await newDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
} }
/** /**
* Generate appropriate filename for export * Generate appropriate filename for export
*/ */
private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string { private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string {
const baseName = originalName.replace(/\.pdf$/i, ''); const baseName = originalName.replace(/\.pdf$/i, '');
if (!appendSuffix) return `${baseName}.pdf`; return `${baseName}.pdf`;
const suffix = selectedOnly ? '_selected' : '_edited';
return `${baseName}${suffix}.pdf`;
} }
/**
* Generate filename for split documents
*/
private generateSplitFilename(baseName: string, partNumber: number): string {
const cleanBaseName = baseName.replace(/\.pdf$/i, '');
return `${cleanBaseName}_part_${partNumber}.pdf`;
}
/** /**
* Download a single file * Download a single file
@ -203,7 +211,7 @@ export class PDFExportService {
* Download multiple files as a ZIP * Download multiple files as a ZIP
*/ */
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> { async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
// For now, download files wherindividually // For now, download files individually
blobs.forEach((blob, index) => { blobs.forEach((blob, index) => {
setTimeout(() => { setTimeout(() => {
this.downloadFile(blob, filenames[index]); this.downloadFile(blob, filenames[index]);
@ -248,8 +256,8 @@ export class PDFExportService {
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages; : pdfDocument.pages;
const splitCount = pagesToExport.reduce((count, page, index) => { const splitCount = pagesToExport.reduce((count, page) => {
return count + (page.splitBefore && index > 0 ? 1 : 0); return count + (page.splitAfter ? 1 : 0);
}, 1); // At least 1 document }, 1); // At least 1 document
// Rough size estimation (very approximate) // Rough size estimation (very approximate)

View File

@ -12,7 +12,7 @@ class PDFWorkerManager {
private static instance: PDFWorkerManager; private static instance: PDFWorkerManager;
private activeDocuments = new Set<any>(); private activeDocuments = new Set<any>();
private workerCount = 0; private workerCount = 0;
private maxWorkers = 3; // Limit concurrent workers private maxWorkers = 10; // Limit concurrent workers
private isInitialized = false; private isInitialized = false;
private constructor() { private constructor() {
@ -33,7 +33,6 @@ class PDFWorkerManager {
if (!this.isInitialized) { if (!this.isInitialized) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
this.isInitialized = true; this.isInitialized = true;
console.log('🏭 PDF.js worker initialized');
} }
} }
@ -52,7 +51,6 @@ class PDFWorkerManager {
): Promise<any> { ): Promise<any> {
// Wait if we've hit the worker limit // Wait if we've hit the worker limit
if (this.activeDocuments.size >= this.maxWorkers) { if (this.activeDocuments.size >= this.maxWorkers) {
console.warn(`🏭 PDF Worker limit reached (${this.maxWorkers}), waiting for available worker...`);
await this.waitForAvailableWorker(); await this.waitForAvailableWorker();
} }
@ -89,8 +87,6 @@ class PDFWorkerManager {
this.activeDocuments.add(pdf); this.activeDocuments.add(pdf);
this.workerCount++; this.workerCount++;
console.log(`🏭 PDF document created (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
return pdf; return pdf;
} catch (error) { } catch (error) {
// If document creation fails, make sure to clean up the loading task // If document creation fails, make sure to clean up the loading task
@ -98,7 +94,7 @@ class PDFWorkerManager {
try { try {
loadingTask.destroy(); loadingTask.destroy();
} catch (destroyError) { } catch (destroyError) {
console.warn('🏭 Error destroying failed loading task:', destroyError); // Silent cleanup failure
} }
} }
throw error; throw error;
@ -114,10 +110,7 @@ class PDFWorkerManager {
pdf.destroy(); pdf.destroy();
this.activeDocuments.delete(pdf); this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1); this.workerCount = Math.max(0, this.workerCount - 1);
console.log(`🏭 PDF document destroyed (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
} catch (error) { } catch (error) {
console.warn('🏭 Error destroying PDF document:', error);
// Still remove from tracking even if destroy failed // Still remove from tracking even if destroy failed
this.activeDocuments.delete(pdf); this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1); this.workerCount = Math.max(0, this.workerCount - 1);
@ -129,8 +122,6 @@ class PDFWorkerManager {
* Destroy all active PDF documents * Destroy all active PDF documents
*/ */
destroyAllDocuments(): void { destroyAllDocuments(): void {
console.log(`🏭 Destroying all PDF documents (${this.activeDocuments.size} active)`);
const documentsToDestroy = Array.from(this.activeDocuments); const documentsToDestroy = Array.from(this.activeDocuments);
documentsToDestroy.forEach(pdf => { documentsToDestroy.forEach(pdf => {
this.destroyDocument(pdf); this.destroyDocument(pdf);
@ -138,8 +129,6 @@ class PDFWorkerManager {
this.activeDocuments.clear(); this.activeDocuments.clear();
this.workerCount = 0; this.workerCount = 0;
console.log('🏭 All PDF documents destroyed');
} }
/** /**
@ -173,29 +162,24 @@ class PDFWorkerManager {
* Force cleanup of all workers (emergency cleanup) * Force cleanup of all workers (emergency cleanup)
*/ */
emergencyCleanup(): void { emergencyCleanup(): void {
console.warn('🏭 Emergency PDF worker cleanup initiated');
// Force destroy all documents // Force destroy all documents
this.activeDocuments.forEach(pdf => { this.activeDocuments.forEach(pdf => {
try { try {
pdf.destroy(); pdf.destroy();
} catch (error) { } catch (error) {
console.warn('🏭 Emergency cleanup - error destroying document:', error); // Silent cleanup
} }
}); });
this.activeDocuments.clear(); this.activeDocuments.clear();
this.workerCount = 0; this.workerCount = 0;
console.warn('🏭 Emergency cleanup completed');
} }
/** /**
* Set maximum concurrent workers * Set maximum concurrent workers
*/ */
setMaxWorkers(max: number): void { setMaxWorkers(max: number): void {
this.maxWorkers = Math.max(1, Math.min(max, 10)); // Between 1-10 workers this.maxWorkers = Math.max(1, Math.min(max, 15)); // Between 1-15 workers for multi-file support
console.log(`🏭 Max workers set to ${this.maxWorkers}`);
} }
} }

View File

@ -40,7 +40,7 @@ export class ThumbnailGenerationService {
private pdfDocumentCache = new Map<string, CachedPDFDocument>(); private pdfDocumentCache = new Map<string, CachedPDFDocument>();
private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached
constructor(private maxWorkers: number = 3) { constructor(private maxWorkers: number = 10) {
// PDF rendering requires DOM access, so we use optimized main thread processing // PDF rendering requires DOM access, so we use optimized main thread processing
} }
@ -207,6 +207,11 @@ export class ThumbnailGenerationService {
// Release reference to PDF document (don't destroy - keep in cache) // Release reference to PDF document (don't destroy - keep in cache)
this.releasePDFDocument(fileId); this.releasePDFDocument(fileId);
// Optionally clean up PDF document from cache to free workers faster
// This can be called after thumbnail generation is complete for a file
this.cleanupCompletedDocument(fileId);
return allResults; return allResults;
} }
@ -289,6 +294,18 @@ export class ThumbnailGenerationService {
} }
} }
/**
* Clean up a PDF document from cache when thumbnail generation is complete
* This frees up workers faster for better performance
*/
cleanupCompletedDocument(fileId: string): void {
const cached = this.pdfDocumentCache.get(fileId);
if (cached && cached.refCount <= 0) {
pdfWorkerManager.destroyDocument(cached.pdf);
this.pdfDocumentCache.delete(fileId);
}
}
destroy(): void { destroy(): void {
this.clearCache(); this.clearCache();
this.clearPDFCache(); this.clearPDFCache();

View File

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

View File

@ -1,10 +1,13 @@
export interface PDFPage { export interface PDFPage {
id: string; id: string;
pageNumber: number; pageNumber: number;
originalPageNumber: number;
thumbnail: string | null; thumbnail: string | null;
rotation: number; rotation: number;
selected: boolean; selected: boolean;
splitBefore?: boolean; splitAfter?: boolean;
isBlankPage?: boolean;
originalFileId?: string;
} }
export interface PDFDocument { export interface PDFDocument {
@ -47,9 +50,17 @@ export interface PageEditorFunctions {
handleRotate: (direction: 'left' | 'right') => void; handleRotate: (direction: 'left' | 'right') => void;
handleDelete: () => void; handleDelete: () => void;
handleSplit: () => void; handleSplit: () => void;
handleSplitAll: () => void;
handlePageBreak: () => void;
handlePageBreakAll: () => void;
handleSelectAll: () => void;
handleDeselectAll: () => void;
handleSetSelectedPages: (pageNumbers: number[]) => void;
onExportSelected: () => void; onExportSelected: () => void;
onExportAll: () => void; onExportAll: () => void;
exportLoading: boolean; exportLoading: boolean;
selectionMode: boolean; selectionMode: boolean;
selectedPages: number[]; selectedPages: number[];
splitPositions: Set<number>;
totalPages: number;
} }