mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
Rewrite page editor
This commit is contained in:
parent
ed61c71db7
commit
4a1c919cb8
@ -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);
|
||||||
|
});
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -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>
|
||||||
|
@ -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
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
|
892
frontend/src/components/pageEditor/commands/pageCommands.ts
Normal file
892
frontend/src/components/pageEditor/commands/pageCommands.ts
Normal 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?.();
|
||||||
|
}
|
||||||
|
}
|
8
frontend/src/components/pageEditor/constants.ts
Normal file
8
frontend/src/components/pageEditor/constants.ts
Normal 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;
|
176
frontend/src/components/pageEditor/hooks/usePageDocument.ts
Normal file
176
frontend/src/components/pageEditor/hooks/usePageDocument.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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' }} />
|
||||||
|
@ -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) {
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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
|
||||||
|
178
frontend/src/services/documentManipulationService.ts
Normal file
178
frontend/src/services/documentManipulationService.ts
Normal 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();
|
@ -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)
|
||||||
|
@ -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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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>;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user