mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-02 13:48:15 +02:00
Ground work for page editor
This commit is contained in:
parent
41efd80dd5
commit
5298cb1607
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@ -25,6 +25,7 @@
|
|||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@ -1394,6 +1395,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@ -4213,6 +4230,11 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@ -4278,6 +4300,22 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||||
|
},
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "3.11.174",
|
"version": "3.11.174",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
334
frontend/src/commands/pageCommands.ts
Normal file
334
frontend/src/commands/pageCommands.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
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) % 360 };
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let restoredPages = [...this.pdfDocument.pages];
|
||||||
|
|
||||||
|
// Insert deleted pages back at their original positions
|
||||||
|
this.deletedPages
|
||||||
|
.sort((a, b) => (this.deletedPositions.get(a.id) || 0) - (this.deletedPositions.get(b.id) || 0))
|
||||||
|
.forEach(page => {
|
||||||
|
const originalIndex = this.deletedPositions.get(page.id) || 0;
|
||||||
|
restoredPages.splice(originalIndex, 0, page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update page numbers
|
||||||
|
restoredPages = restoredPages.map((page, index) => ({
|
||||||
|
...page,
|
||||||
|
pageNumber: index + 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.setPdfDocument({
|
||||||
|
...this.pdfDocument,
|
||||||
|
pages: restoredPages,
|
||||||
|
totalPages: restoredPages.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,10 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Paper, Button, Group, Text, Stack, Center, Checkbox, ScrollArea, Box, Tooltip, ActionIcon, Notification
|
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
||||||
|
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
|
||||||
|
Stack, Group, Paper, SimpleGrid
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import UndoIcon from "@mui/icons-material/Undo";
|
import UndoIcon from "@mui/icons-material/Undo";
|
||||||
import RedoIcon from "@mui/icons-material/Redo";
|
import RedoIcon from "@mui/icons-material/Redo";
|
||||||
@ -11,8 +14,24 @@ import DownloadIcon from "@mui/icons-material/Download";
|
|||||||
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 ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||||
|
import ConstructionIcon from "@mui/icons-material/Construction";
|
||||||
|
import EventListIcon from "@mui/icons-material/EventList";
|
||||||
|
import DeselectIcon from "@mui/icons-material/Deselect";
|
||||||
|
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||||
|
import { usePDFProcessor } from "../hooks/usePDFProcessor";
|
||||||
|
import { PDFDocument, PDFPage } from "../types/pageEditor";
|
||||||
|
import { fileStorage } from "../services/fileStorage";
|
||||||
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
import { useUndoRedo } from "../hooks/useUndoRedo";
|
||||||
|
import {
|
||||||
|
RotatePagesCommand,
|
||||||
|
DeletePagesCommand,
|
||||||
|
ReorderPageCommand,
|
||||||
|
ToggleSplitCommand
|
||||||
|
} from "../commands/pageCommands";
|
||||||
|
import { pdfExportService } from "../services/pdfExportService";
|
||||||
|
|
||||||
export interface PageEditorProps {
|
export interface PageEditorProps {
|
||||||
file: { file: File; url: string } | null;
|
file: { file: File; url: string } | null;
|
||||||
@ -21,8 +40,6 @@ export interface PageEditorProps {
|
|||||||
setDownloadUrl?: (url: string | null) => void;
|
setDownloadUrl?: (url: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DUMMY_PAGE_COUNT = 8; // Replace with real page count from PDF
|
|
||||||
|
|
||||||
const PageEditor: React.FC<PageEditorProps> = ({
|
const PageEditor: React.FC<PageEditorProps> = ({
|
||||||
file,
|
file,
|
||||||
setFile,
|
setFile,
|
||||||
@ -30,168 +47,556 @@ const PageEditor: React.FC<PageEditorProps> = ({
|
|||||||
setDownloadUrl,
|
setDownloadUrl,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPages, setSelectedPages] = useState<number[]>([]);
|
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
|
||||||
|
|
||||||
|
const [pdfDocument, setPdfDocument] = useState<PDFDocument | null>(null);
|
||||||
|
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [undoStack, setUndoStack] = useState<number[][]>([]);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [redoStack, setRedoStack] = useState<number[][]>([]);
|
const [csvInput, setCsvInput] = useState<string>("");
|
||||||
|
const [showPageSelect, setShowPageSelect] = useState(false);
|
||||||
|
const [filename, setFilename] = useState<string>("");
|
||||||
|
const [draggedPage, setDraggedPage] = useState<string | null>(null);
|
||||||
|
const [exportLoading, setExportLoading] = useState(false);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
||||||
|
const fileInputRef = useRef<() => void>(null);
|
||||||
|
|
||||||
// Dummy page thumbnails
|
// Undo/Redo system
|
||||||
const pages = Array.from({ length: DUMMY_PAGE_COUNT }, (_, i) => i + 1);
|
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||||
|
|
||||||
const selectAll = () => setSelectedPages(pages);
|
// Process uploaded file
|
||||||
const deselectAll = () => setSelectedPages([]);
|
const handleFileUpload = useCallback(async (uploadedFile: File) => {
|
||||||
const togglePage = (page: number) =>
|
if (!uploadedFile || uploadedFile.type !== 'application/pdf') {
|
||||||
setSelectedPages((prev) =>
|
setError('Please upload a valid PDF file');
|
||||||
prev.includes(page) ? prev.filter((p) => p !== page) : [...prev, page]
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const document = await processPDFFile(uploadedFile);
|
||||||
|
setPdfDocument(document);
|
||||||
|
setFilename(uploadedFile.name.replace(/\.pdf$/i, ''));
|
||||||
|
setSelectedPages([]);
|
||||||
|
|
||||||
|
if (document.pages.length > 0) {
|
||||||
|
const thumbnail = await generateThumbnailForFile(uploadedFile);
|
||||||
|
await fileStorage.storeFile(uploadedFile, thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setFile) {
|
||||||
|
const fileUrl = URL.createObjectURL(uploadedFile);
|
||||||
|
setFile({ file: uploadedFile, url: fileUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('PDF processing error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [processPDFFile, setFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (file?.file && !pdfDocument) {
|
||||||
|
handleFileUpload(file.file);
|
||||||
|
}
|
||||||
|
}, [file, pdfDocument, handleFileUpload]);
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
if (pdfDocument) {
|
||||||
|
setSelectedPages(pdfDocument.pages.map(p => p.id));
|
||||||
|
}
|
||||||
|
}, [pdfDocument]);
|
||||||
|
|
||||||
|
const deselectAll = useCallback(() => setSelectedPages([]), []);
|
||||||
|
|
||||||
|
const togglePage = useCallback((pageId: string) => {
|
||||||
|
setSelectedPages(prev =>
|
||||||
|
prev.includes(pageId)
|
||||||
|
? prev.filter(id => id !== pageId)
|
||||||
|
: [...prev, pageId]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseCSVInput = useCallback((csv: string) => {
|
||||||
|
if (!pdfDocument) return [];
|
||||||
|
|
||||||
|
const pageIds: string[] = [];
|
||||||
|
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
ranges.forEach(range => {
|
||||||
|
if (range.includes('-')) {
|
||||||
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||||
|
for (let i = start; i <= end && i <= pdfDocument.totalPages; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
const page = pdfDocument.pages.find(p => p.pageNumber === i);
|
||||||
|
if (page) pageIds.push(page.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pageNum = parseInt(range);
|
||||||
|
if (pageNum > 0 && pageNum <= pdfDocument.totalPages) {
|
||||||
|
const page = pdfDocument.pages.find(p => p.pageNumber === pageNum);
|
||||||
|
if (page) pageIds.push(page.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return pageIds;
|
||||||
|
}, [pdfDocument]);
|
||||||
|
|
||||||
|
const updatePagesFromCSV = useCallback(() => {
|
||||||
|
const pageIds = parseCSVInput(csvInput);
|
||||||
|
setSelectedPages(pageIds);
|
||||||
|
}, [csvInput, parseCSVInput]);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((pageId: string) => {
|
||||||
|
setDraggedPage(pageId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent, targetPageId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return;
|
||||||
|
|
||||||
|
const targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId);
|
||||||
|
if (targetIndex === -1) return;
|
||||||
|
|
||||||
|
const command = new ReorderPageCommand(
|
||||||
|
pdfDocument,
|
||||||
|
setPdfDocument,
|
||||||
|
draggedPage,
|
||||||
|
targetIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
// Undo/redo logic for selection
|
executeCommand(command);
|
||||||
const handleUndo = () => {
|
setDraggedPage(null);
|
||||||
if (undoStack.length > 0) {
|
setStatus('Page reordered');
|
||||||
setRedoStack([selectedPages, ...redoStack]);
|
}, [draggedPage, pdfDocument, executeCommand]);
|
||||||
setSelectedPages(undoStack[0]);
|
|
||||||
setUndoStack(undoStack.slice(1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleRedo = () => {
|
|
||||||
if (redoStack.length > 0) {
|
|
||||||
setUndoStack([selectedPages, ...undoStack]);
|
|
||||||
setSelectedPages(redoStack[0]);
|
|
||||||
setRedoStack(redoStack.slice(1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Example action handlers (replace with real API calls)
|
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
||||||
const handleRotateLeft = () => setStatus(t("pageEditor.rotatedLeft", "Rotated left: ") + selectedPages.join(", "));
|
if (!pdfDocument || selectedPages.length === 0) return;
|
||||||
const handleRotateRight = () => setStatus(t("pageEditor.rotatedRight", "Rotated right: ") + selectedPages.join(", "));
|
|
||||||
const handleDelete = () => setStatus(t("pageEditor.deleted", "Deleted: ") + selectedPages.join(", "));
|
|
||||||
const handleMoveLeft = () => setStatus(t("pageEditor.movedLeft", "Moved left: ") + selectedPages.join(", "));
|
|
||||||
const handleMoveRight = () => setStatus(t("pageEditor.movedRight", "Moved right: ") + selectedPages.join(", "));
|
|
||||||
const handleSplit = () => setStatus(t("pageEditor.splitAt", "Split at: ") + selectedPages.join(", "));
|
|
||||||
const handleInsertPageBreak = () => setStatus(t("pageEditor.insertedPageBreak", "Inserted page break at: ") + selectedPages.join(", "));
|
|
||||||
const handleAddFile = () => setStatus(t("pageEditor.addFileNotImplemented", "Add file not implemented in demo"));
|
|
||||||
|
|
||||||
if (!file) {
|
const rotation = direction === 'left' ? -90 : 90;
|
||||||
return (
|
const command = new RotatePagesCommand(
|
||||||
<Paper shadow="xs" radius="md" p="md">
|
pdfDocument,
|
||||||
<Center>
|
setPdfDocument,
|
||||||
<Text color="dimmed">{t("pageEditor.noPdfLoaded", "No PDF loaded. Please upload a PDF to edit.")}</Text>
|
selectedPages,
|
||||||
</Center>
|
rotation
|
||||||
</Paper>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
executeCommand(command);
|
||||||
|
setStatus(`Rotated ${selectedPages.length} pages ${direction}`);
|
||||||
|
}, [pdfDocument, selectedPages, executeCommand]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!pdfDocument || selectedPages.length === 0) return;
|
||||||
|
|
||||||
|
const command = new DeletePagesCommand(
|
||||||
|
pdfDocument,
|
||||||
|
setPdfDocument,
|
||||||
|
selectedPages
|
||||||
|
);
|
||||||
|
|
||||||
|
executeCommand(command);
|
||||||
|
setSelectedPages([]);
|
||||||
|
setStatus(`Deleted ${selectedPages.length} pages`);
|
||||||
|
}, [pdfDocument, selectedPages, executeCommand]);
|
||||||
|
|
||||||
|
const handleSplit = useCallback(() => {
|
||||||
|
if (!pdfDocument || selectedPages.length === 0) return;
|
||||||
|
|
||||||
|
const command = new ToggleSplitCommand(
|
||||||
|
pdfDocument,
|
||||||
|
setPdfDocument,
|
||||||
|
selectedPages
|
||||||
|
);
|
||||||
|
|
||||||
|
executeCommand(command);
|
||||||
|
setStatus(`Split markers toggled for ${selectedPages.length} pages`);
|
||||||
|
}, [pdfDocument, selectedPages, executeCommand]);
|
||||||
|
|
||||||
|
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||||
|
if (!pdfDocument) return;
|
||||||
|
|
||||||
|
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||||
|
const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly);
|
||||||
|
setExportPreview(preview);
|
||||||
|
setShowExportModal(true);
|
||||||
|
}, [pdfDocument, selectedPages]);
|
||||||
|
|
||||||
|
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
||||||
|
if (!pdfDocument) return;
|
||||||
|
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||||
|
const errors = pdfExportService.validateExport(pdfDocument, exportPageIds, selectedOnly);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setError(errors.join(', '));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore);
|
||||||
|
|
||||||
|
if (hasSplitMarkers) {
|
||||||
|
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
|
||||||
|
selectedOnly,
|
||||||
|
filename,
|
||||||
|
splitDocuments: true
|
||||||
|
}) as { blobs: Blob[]; filenames: string[] };
|
||||||
|
|
||||||
|
result.blobs.forEach((blob, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
pdfExportService.downloadFile(blob, result.filenames[index]);
|
||||||
|
}, index * 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatus(`Exported ${result.blobs.length} split documents`);
|
||||||
|
} else {
|
||||||
|
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
|
||||||
|
selectedOnly,
|
||||||
|
filename
|
||||||
|
}) as { blob: Blob; filename: string };
|
||||||
|
|
||||||
|
pdfExportService.downloadFile(result.blob, result.filename);
|
||||||
|
setStatus('PDF exported successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Export failed';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
}, [pdfDocument, selectedPages, filename]);
|
||||||
|
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
if (undo()) {
|
||||||
|
setStatus('Operation undone');
|
||||||
|
}
|
||||||
|
}, [undo]);
|
||||||
|
|
||||||
|
const handleRedo = useCallback(() => {
|
||||||
|
if (redo()) {
|
||||||
|
setStatus('Operation redone');
|
||||||
|
}
|
||||||
|
}, [redo]);
|
||||||
|
|
||||||
|
if (!pdfDocument) {
|
||||||
return (
|
return (
|
||||||
<Paper shadow="xs" radius="md" p="md">
|
<Container>
|
||||||
<Group align="flex-start" gap="lg">
|
<Paper shadow="xs" radius="md" p="md" pos="relative">
|
||||||
{/* Sidebar */}
|
<LoadingOverlay visible={loading || pdfLoading} />
|
||||||
<Stack w={180} gap="xs">
|
|
||||||
<Text fw={600} size="lg">{t("pageEditor.title", "PDF Multitool")}</Text>
|
<Group mb="md">
|
||||||
<Button onClick={selectAll} fullWidth variant="light">{t("multiTool.selectAll", "Select All")}</Button>
|
<ConstructionIcon />
|
||||||
<Button onClick={deselectAll} fullWidth variant="light">{t("multiTool.deselectAll", "Deselect All")}</Button>
|
<Text size="lg" fw={600}>PDF Multitool</Text>
|
||||||
<Button onClick={handleUndo} leftSection={<UndoIcon fontSize="small" />} fullWidth disabled={undoStack.length === 0}>{t("multiTool.undo", "Undo")}</Button>
|
</Group>
|
||||||
<Button onClick={handleRedo} leftSection={<RedoIcon fontSize="small" />} fullWidth disabled={redoStack.length === 0}>{t("multiTool.redo", "Redo")}</Button>
|
|
||||||
<Button onClick={handleAddFile} leftSection={<AddIcon fontSize="small" />} fullWidth>{t("multiTool.addFile", "Add File")}</Button>
|
{error && (
|
||||||
<Button onClick={handleInsertPageBreak} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.insertPageBreak", "Insert Page Break")}</Button>
|
<Alert color="red" mb="md" onClose={() => setError(null)}>
|
||||||
<Button onClick={handleSplit} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.split", "Split")}</Button>
|
{error}
|
||||||
<Button
|
</Alert>
|
||||||
component="a"
|
)}
|
||||||
href={downloadUrl || "#"}
|
|
||||||
download="edited.pdf"
|
<Dropzone
|
||||||
leftSection={<DownloadIcon fontSize="small" />}
|
onDrop={(files) => files[0] && handleFileUpload(files[0])}
|
||||||
fullWidth
|
accept={["application/pdf"]}
|
||||||
color="green"
|
multiple={false}
|
||||||
variant="light"
|
h={300}
|
||||||
disabled={!downloadUrl}
|
|
||||||
>
|
>
|
||||||
{t("multiTool.downloadAll", "Download All")}
|
<Center h={250}>
|
||||||
</Button>
|
<Stack align="center" gap="md">
|
||||||
<Button
|
<UploadFileIcon style={{ fontSize: 48 }} />
|
||||||
component="a"
|
<Text size="lg" fw={500}>
|
||||||
href={downloadUrl || "#"}
|
Drop a PDF file here or click to upload
|
||||||
download="selected.pdf"
|
</Text>
|
||||||
leftSection={<DownloadIcon fontSize="small" />}
|
<Text size="sm" c="dimmed">
|
||||||
fullWidth
|
Supports PDF files only
|
||||||
color="blue"
|
</Text>
|
||||||
variant="light"
|
|
||||||
disabled={!downloadUrl || selectedPages.length === 0}
|
|
||||||
>
|
|
||||||
{t("multiTool.downloadSelected", "Download Selected")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => setFile && setFile(null)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{t("pageEditor.closePdf", "Close PDF")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Dropzone>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Main multitool area */}
|
return (
|
||||||
<Box style={{ flex: 1 }}>
|
<Container>
|
||||||
<Group mb="sm">
|
<Paper shadow="xs" radius="md" p="md" pos="relative">
|
||||||
<Tooltip label={t("multiTool.rotateLeft", "Rotate Left")}>
|
<LoadingOverlay visible={loading || pdfLoading} />
|
||||||
<ActionIcon onClick={handleRotateLeft} disabled={selectedPages.length === 0} color="blue" variant="light">
|
|
||||||
|
<Group mb="md">
|
||||||
|
<ConstructionIcon />
|
||||||
|
<Text size="lg" fw={600}>PDF Multitool</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group mb="md">
|
||||||
|
<TextInput
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
|
placeholder="Enter filename"
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setShowPageSelect(!showPageSelect)}>
|
||||||
|
Select Pages
|
||||||
|
</Button>
|
||||||
|
<Button onClick={selectAll}>Select All</Button>
|
||||||
|
<Button onClick={deselectAll}>Deselect All</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{showPageSelect && (
|
||||||
|
<Paper p="md" mb="md" withBorder>
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
value={csvInput}
|
||||||
|
onChange={(e) => setCsvInput(e.target.value)}
|
||||||
|
placeholder="1,3,5-10"
|
||||||
|
label="Page Selection"
|
||||||
|
onBlur={updatePagesFromCSV}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button onClick={updatePagesFromCSV} mt="xl">
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{selectedPages.length > 0 && (
|
||||||
|
<Text size="sm" c="dimmed" mt="sm">
|
||||||
|
Selected: {selectedPages.length} pages
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group mb="md">
|
||||||
|
<Tooltip label="Undo">
|
||||||
|
<ActionIcon onClick={handleUndo} disabled={!canUndo}>
|
||||||
|
<UndoIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Redo">
|
||||||
|
<ActionIcon onClick={handleRedo} disabled={!canRedo}>
|
||||||
|
<RedoIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Rotate Left">
|
||||||
|
<ActionIcon onClick={() => handleRotate('left')} disabled={selectedPages.length === 0}>
|
||||||
<RotateLeftIcon />
|
<RotateLeftIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t("multiTool.rotateRight", "Rotate Right")}>
|
<Tooltip label="Rotate Right">
|
||||||
<ActionIcon onClick={handleRotateRight} disabled={selectedPages.length === 0} color="blue" variant="light">
|
<ActionIcon onClick={() => handleRotate('right')} disabled={selectedPages.length === 0}>
|
||||||
<RotateRightIcon />
|
<RotateRightIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t("delete", "Delete")}>
|
<Tooltip label="Delete">
|
||||||
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red" variant="light">
|
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red">
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t("multiTool.moveLeft", "Move Left")}>
|
<Tooltip label="Split">
|
||||||
<ActionIcon onClick={handleMoveLeft} disabled={selectedPages.length === 0} color="gray" variant="light">
|
<ActionIcon onClick={handleSplit} disabled={selectedPages.length === 0}>
|
||||||
<ArrowBackIosNewIcon />
|
<ContentCutIcon />
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={t("multiTool.moveRight", "Move Right")}>
|
|
||||||
<ActionIcon onClick={handleMoveRight} disabled={selectedPages.length === 0} color="gray" variant="light">
|
|
||||||
<ArrowForwardIosIcon />
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
<ScrollArea h={350}>
|
|
||||||
<Group>
|
<SimpleGrid cols={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing="md">
|
||||||
{pages.map((page) => (
|
{pdfDocument.pages.map((page) => (
|
||||||
<Stack key={page} align="center" gap={2}>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedPages.includes(page)}
|
|
||||||
onChange={() => togglePage(page)}
|
|
||||||
label={t("page", "Page") + ` ${page}`}
|
|
||||||
/>
|
|
||||||
<Box
|
<Box
|
||||||
w={60}
|
key={page.id}
|
||||||
h={80}
|
style={{
|
||||||
bg={selectedPages.includes(page) ? "blue.1" : "gray.1"}
|
borderRadius: 8,
|
||||||
style={{ border: "1px solid #ccc", borderRadius: 4 }}
|
padding: 8,
|
||||||
|
position: 'relative',
|
||||||
|
cursor: 'grab',
|
||||||
|
...(selectedPages.includes(page.id)
|
||||||
|
? { border: '2px solid blue' }
|
||||||
|
: { border: '1px solid #ccc' }
|
||||||
|
),
|
||||||
|
...(page.splitBefore
|
||||||
|
? { borderLeft: '4px dashed orange' }
|
||||||
|
: {}
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(page.id)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, page.id)}
|
||||||
>
|
>
|
||||||
{/* Replace with real thumbnail */}
|
<Stack align="center" gap={4}>
|
||||||
<Center h="100%">
|
{showPageSelect && (
|
||||||
<Text size="xs" color="dimmed">
|
<Checkbox
|
||||||
{page}
|
checked={selectedPages.includes(page.id)}
|
||||||
|
onChange={() => togglePage(page.id)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box w={120} h={160} pos="relative">
|
||||||
|
<img
|
||||||
|
src={page.thumbnail}
|
||||||
|
alt={`Page ${page.pageNumber}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: 4,
|
||||||
|
transform: `rotate(${page.rotation}deg)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={500}
|
||||||
|
c="white"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
left: 4,
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page.pageNumber}
|
||||||
</Text>
|
</Text>
|
||||||
</Center>
|
|
||||||
|
<DragIndicatorIcon
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
color: 'rgba(0,0,0,0.5)',
|
||||||
|
fontSize: 16
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Page {page.pageNumber}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</ScrollArea>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="md">
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => {
|
||||||
|
setPdfDocument(null);
|
||||||
|
setFile && setFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close PDF
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
leftSection={<DownloadIcon />}
|
||||||
|
disabled={selectedPages.length === 0 || exportLoading}
|
||||||
|
loading={exportLoading}
|
||||||
|
onClick={() => showExportPreview(true)}
|
||||||
|
>
|
||||||
|
Download Selected
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftSection={<DownloadIcon />}
|
||||||
|
color="green"
|
||||||
|
disabled={exportLoading}
|
||||||
|
loading={exportLoading}
|
||||||
|
onClick={() => showExportPreview(false)}
|
||||||
|
>
|
||||||
|
Download All
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={showExportModal}
|
||||||
|
onClose={() => setShowExportModal(false)}
|
||||||
|
title="Export Preview"
|
||||||
|
>
|
||||||
|
{exportPreview && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text>Pages to export:</Text>
|
||||||
|
<Text fw={500}>{exportPreview.pageCount}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{exportPreview.splitCount > 1 && (
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text>Split into documents:</Text>
|
||||||
|
<Text fw={500}>{exportPreview.splitCount}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text>Estimated size:</Text>
|
||||||
|
<Text fw={500}>{exportPreview.estimatedSize}</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && (
|
||||||
|
<Alert color="blue">
|
||||||
|
This will create multiple PDF files based on split markers.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setShowExportModal(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
loading={exportLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportModal(false);
|
||||||
|
const selectedOnly = exportPreview.pageCount < (pdfDocument?.totalPages || 0);
|
||||||
|
handleExport(selectedOnly);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export PDF
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="application/pdf"
|
||||||
|
onChange={(file) => file && handleFileUpload(file)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
<Notification color="blue" mt="md" onClose={() => setStatus(null)}>
|
<Notification
|
||||||
|
color="blue"
|
||||||
|
mt="md"
|
||||||
|
onClose={() => setStatus(null)}
|
||||||
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||||
|
>
|
||||||
{status}
|
{status}
|
||||||
</Notification>
|
</Notification>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
92
frontend/src/hooks/usePDFProcessor.ts
Normal file
92
frontend/src/hooks/usePDFProcessor.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getDocument } from 'pdfjs-dist';
|
||||||
|
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||||
|
|
||||||
|
export function usePDFProcessor() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const generatePageThumbnail = useCallback(async (
|
||||||
|
file: File,
|
||||||
|
pageNumber: number,
|
||||||
|
scale: number = 0.5
|
||||||
|
): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||||
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Could not get canvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise;
|
||||||
|
const thumbnail = canvas.toDataURL();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
pdf.destroy();
|
||||||
|
|
||||||
|
return thumbnail;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate thumbnail:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||||
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
|
const pages: PDFPage[] = [];
|
||||||
|
|
||||||
|
// Generate thumbnails for all pages
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
const thumbnail = await generatePageThumbnail(file, i);
|
||||||
|
pages.push({
|
||||||
|
id: `${file.name}-page-${i}`,
|
||||||
|
pageNumber: i,
|
||||||
|
thumbnail,
|
||||||
|
rotation: 0,
|
||||||
|
selected: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
pdf.destroy();
|
||||||
|
|
||||||
|
const document: PDFDocument = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
name: file.name,
|
||||||
|
file,
|
||||||
|
pages,
|
||||||
|
totalPages
|
||||||
|
};
|
||||||
|
|
||||||
|
return document;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to process PDF';
|
||||||
|
setError(errorMessage);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [generatePageThumbnail]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
processPDFFile,
|
||||||
|
generatePageThumbnail,
|
||||||
|
loading,
|
||||||
|
error
|
||||||
|
};
|
||||||
|
}
|
68
frontend/src/hooks/useUndoRedo.ts
Normal file
68
frontend/src/hooks/useUndoRedo.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
execute(): void;
|
||||||
|
undo(): void;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandSequence {
|
||||||
|
commands: Command[];
|
||||||
|
execute(): void;
|
||||||
|
undo(): void;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUndoRedo() {
|
||||||
|
const [undoStack, setUndoStack] = useState<(Command | CommandSequence)[]>([]);
|
||||||
|
const [redoStack, setRedoStack] = useState<(Command | CommandSequence)[]>([]);
|
||||||
|
|
||||||
|
const executeCommand = useCallback((command: Command | CommandSequence) => {
|
||||||
|
command.execute();
|
||||||
|
setUndoStack(prev => [command, ...prev]);
|
||||||
|
setRedoStack([]); // Clear redo stack when new command is executed
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (undoStack.length === 0) return false;
|
||||||
|
|
||||||
|
const command = undoStack[0];
|
||||||
|
command.undo();
|
||||||
|
|
||||||
|
setUndoStack(prev => prev.slice(1));
|
||||||
|
setRedoStack(prev => [command, ...prev]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [undoStack]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (redoStack.length === 0) return false;
|
||||||
|
|
||||||
|
const command = redoStack[0];
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
setRedoStack(prev => prev.slice(1));
|
||||||
|
setUndoStack(prev => [command, ...prev]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [redoStack]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setUndoStack([]);
|
||||||
|
setRedoStack([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canUndo = undoStack.length > 0;
|
||||||
|
const canRedo = redoStack.length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
executeCommand,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
clear,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
undoStack,
|
||||||
|
redoStack
|
||||||
|
};
|
||||||
|
}
|
263
frontend/src/services/pdfExportService.ts
Normal file
263
frontend/src/services/pdfExportService.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
|
||||||
|
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
selectedOnly?: boolean;
|
||||||
|
filename?: string;
|
||||||
|
splitDocuments?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PDFExportService {
|
||||||
|
/**
|
||||||
|
* Export PDF document with applied operations
|
||||||
|
*/
|
||||||
|
async exportPDF(
|
||||||
|
pdfDocument: PDFDocument,
|
||||||
|
selectedPageIds: string[] = [],
|
||||||
|
options: ExportOptions = {}
|
||||||
|
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
||||||
|
const { selectedOnly = false, filename, splitDocuments = false } = 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load original PDF once
|
||||||
|
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
|
||||||
|
const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
|
||||||
|
|
||||||
|
if (splitDocuments) {
|
||||||
|
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);
|
||||||
|
return { blob, filename: exportFilename };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PDF export error:', error);
|
||||||
|
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single PDF document with all operations applied
|
||||||
|
*/
|
||||||
|
private async createSingleDocument(
|
||||||
|
sourceDoc: PDFLibDocument,
|
||||||
|
pages: PDFPage[]
|
||||||
|
): Promise<Blob> {
|
||||||
|
const newDoc = await PDFLibDocument.create();
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
// Get the original page from source document
|
||||||
|
const sourcePageIndex = page.pageNumber - 1;
|
||||||
|
|
||||||
|
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||||
|
// Copy the page
|
||||||
|
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||||
|
|
||||||
|
// Apply rotation
|
||||||
|
if (page.rotation !== 0) {
|
||||||
|
copiedPage.setRotation(degrees(page.rotation));
|
||||||
|
}
|
||||||
|
|
||||||
|
newDoc.addPage(copiedPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create multiple PDF documents based on split markers
|
||||||
|
*/
|
||||||
|
private async createSplitDocuments(
|
||||||
|
sourceDoc: PDFLibDocument,
|
||||||
|
pages: PDFPage[],
|
||||||
|
baseFilename: string
|
||||||
|
): Promise<{ blobs: Blob[]; filenames: string[] }> {
|
||||||
|
const splitPoints: number[] = [];
|
||||||
|
const blobs: Blob[] = [];
|
||||||
|
const filenames: string[] = [];
|
||||||
|
|
||||||
|
// Find split points
|
||||||
|
pages.forEach((page, index) => {
|
||||||
|
if (page.splitBefore && index > 0) {
|
||||||
|
splitPoints.push(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add end point
|
||||||
|
splitPoints.push(pages.length);
|
||||||
|
|
||||||
|
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 = page.pageNumber - 1;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set metadata
|
||||||
|
newDoc.setCreator('Stirling PDF');
|
||||||
|
newDoc.setProducer('Stirling PDF');
|
||||||
|
newDoc.setTitle(`${baseFilename} - Part ${partNumber}`);
|
||||||
|
|
||||||
|
const pdfBytes = await newDoc.save();
|
||||||
|
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||||
|
const filename = this.generateSplitFilename(baseFilename, partNumber);
|
||||||
|
|
||||||
|
blobs.push(blob);
|
||||||
|
filenames.push(filename);
|
||||||
|
partNumber++;
|
||||||
|
}
|
||||||
|
|
||||||
|
startIndex = endIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { blobs, filenames };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate appropriate filename for export
|
||||||
|
*/
|
||||||
|
private generateFilename(originalName: string, selectedOnly: boolean): string {
|
||||||
|
const baseName = originalName.replace(/\.pdf$/i, '');
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
downloadFile(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.style.display = 'none';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Clean up the URL after a short delay
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download multiple files as a ZIP
|
||||||
|
*/
|
||||||
|
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
|
||||||
|
// For now, download files individually
|
||||||
|
// TODO: Implement ZIP creation when needed
|
||||||
|
blobs.forEach((blob, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.downloadFile(blob, filenames[index]);
|
||||||
|
}, index * 500); // Stagger downloads
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate PDF operations before export
|
||||||
|
*/
|
||||||
|
validateExport(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (selectedOnly && selectedPageIds.length === 0) {
|
||||||
|
errors.push('No pages selected for export');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pdfDocument.pages.length === 0) {
|
||||||
|
errors.push('No pages available to export');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagesToExport = selectedOnly
|
||||||
|
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||||
|
: pdfDocument.pages;
|
||||||
|
|
||||||
|
if (pagesToExport.length === 0) {
|
||||||
|
errors.push('No valid pages to export after applying filters');
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get export preview information
|
||||||
|
*/
|
||||||
|
getExportInfo(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): {
|
||||||
|
pageCount: number;
|
||||||
|
splitCount: number;
|
||||||
|
estimatedSize: string;
|
||||||
|
} {
|
||||||
|
const pagesToExport = selectedOnly
|
||||||
|
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||||
|
: pdfDocument.pages;
|
||||||
|
|
||||||
|
const splitCount = pagesToExport.reduce((count, page, index) => {
|
||||||
|
return count + (page.splitBefore && index > 0 ? 1 : 0);
|
||||||
|
}, 1); // At least 1 document
|
||||||
|
|
||||||
|
// Rough size estimation (very approximate)
|
||||||
|
const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
|
||||||
|
const estimatedBytes = avgPageSize * pagesToExport.length;
|
||||||
|
const estimatedSize = this.formatFileSize(estimatedBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageCount: pagesToExport.length,
|
||||||
|
splitCount,
|
||||||
|
estimatedSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display
|
||||||
|
*/
|
||||||
|
private formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const pdfExportService = new PDFExportService();
|
27
frontend/src/types/pageEditor.ts
Normal file
27
frontend/src/types/pageEditor.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export interface PDFPage {
|
||||||
|
id: string;
|
||||||
|
pageNumber: number;
|
||||||
|
thumbnail: string;
|
||||||
|
rotation: number;
|
||||||
|
selected: boolean;
|
||||||
|
splitBefore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PDFDocument {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
file: File;
|
||||||
|
pages: PDFPage[];
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageOperation {
|
||||||
|
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert';
|
||||||
|
pageIds: string[];
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UndoRedoState {
|
||||||
|
operations: PageOperation[];
|
||||||
|
currentIndex: number;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user