Page breaks

This commit is contained in:
Reece Browne 2025-08-25 17:11:23 +01:00
parent 7cf0806749
commit 2bcb506a7b
8 changed files with 256 additions and 34 deletions

View File

@ -112,6 +112,8 @@ export default function Workbench() {
onDelete={pageEditorFunctions.handleDelete} onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit} onSplit={pageEditorFunctions.handleSplit}
onSplitAll={pageEditorFunctions.handleSplitAll} onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportSelected={pageEditorFunctions.onExportSelected} onExportSelected={pageEditorFunctions.onExportSelected}
onExportAll={pageEditorFunctions.onExportAll} onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading} exportLoading={pageEditorFunctions.exportLoading}

View File

@ -27,6 +27,8 @@ import {
BulkRotateCommand, BulkRotateCommand,
BulkSplitCommand, BulkSplitCommand,
SplitAllCommand, SplitAllCommand,
PageBreakCommand,
BulkPageBreakCommand,
UndoManager UndoManager
} from './commands/pageCommands'; } from './commands/pageCommands';
import { usePageDocument } from './hooks/usePageDocument'; import { usePageDocument } from './hooks/usePageDocument';
@ -42,6 +44,8 @@ export interface PageEditorProps {
handleDelete: () => void; handleDelete: () => void;
handleSplit: () => void; handleSplit: () => void;
handleSplitAll: () => void; handleSplitAll: () => void;
handlePageBreak: () => void;
handlePageBreakAll: () => void;
showExportPreview: (selectedOnly: boolean) => void; showExportPreview: (selectedOnly: boolean) => void;
onExportSelected: () => void; onExportSelected: () => void;
onExportAll: () => void; onExportAll: () => void;
@ -266,6 +270,31 @@ const PageEditor = ({
undoManagerRef.current.executeCommand(splitAllCommand); undoManagerRef.current.executeCommand(splitAllCommand);
}, [displayDocument, splitPositions]); }, [displayDocument, splitPositions]);
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageNumbers.length === 0) return;
console.log('Insert page breaks after selected pages:', selectedPageNumbers);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument,
setSelectedPageNumbers
);
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageNumbers, displayDocument]);
const handlePageBreakAll = useCallback(() => {
if (!displayDocument) return;
const pageBreakAllCommand = new BulkPageBreakCommand(
() => displayDocument,
setEditedDocument,
setSelectedPageNumbers
);
undoManagerRef.current.executeCommand(pageBreakAllCommand);
}, [displayDocument]);
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => { const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => {
if (!displayDocument) return; if (!displayDocument) return;
@ -429,6 +458,8 @@ const PageEditor = ({
handleDelete, handleDelete,
handleSplit, handleSplit,
handleSplitAll, handleSplitAll,
handlePageBreak,
handlePageBreakAll,
showExportPreview: handleExportPreview, showExportPreview: handleExportPreview,
onExportSelected, onExportSelected,
onExportAll, onExportAll,
@ -443,8 +474,8 @@ const PageEditor = ({
} }
}, [ }, [
onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll, onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll,
handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading, selectionMode, selectedPageNumbers, handlePageBreak, handlePageBreakAll, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading,
splitPositions, displayDocument?.pages.length, closePdf selectionMode, selectedPageNumbers, splitPositions, displayDocument?.pages.length, closePdf
]); ]);
// Display all pages - use edited or original document // Display all pages - use edited or original document

View File

@ -12,6 +12,7 @@ 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 CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
interface PageEditorControlsProps { interface PageEditorControlsProps {
// Close/Reset functions // Close/Reset functions
@ -28,6 +29,8 @@ interface PageEditorControlsProps {
onDelete: () => void; onDelete: () => void;
onSplit: () => void; onSplit: () => void;
onSplitAll: () => void; onSplitAll: () => void;
onPageBreak: () => void;
onPageBreakAll: () => void;
// Export functions // Export functions
onExportSelected: () => void; onExportSelected: () => void;
@ -53,6 +56,8 @@ const PageEditorControls = ({
onDelete, onDelete,
onSplit, onSplit,
onSplitAll, onSplitAll,
onPageBreak,
onPageBreakAll,
onExportSelected, onExportSelected,
onExportAll, onExportAll,
exportLoading, exportLoading,
@ -79,6 +84,16 @@ const PageEditorControls = ({
return hasAllSplits ? "Remove All Splits" : "Split All"; return hasAllSplits ? "Remove All Splits" : "Split All";
}; };
// Calculate page break tooltip text
const getPageBreakTooltip = () => {
if (selectionMode) {
return selectedPages.length > 0
? `Insert ${selectedPages.length} Page Break${selectedPages.length > 1 ? 's' : ''}`
: "Insert Page Breaks";
}
return "Insert Page Breaks After All Pages";
};
return ( return (
<div <div
style={{ style={{
@ -183,6 +198,17 @@ const PageEditorControls = ({
<ContentCutIcon /> <ContentCutIcon />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={getPageBreakTooltip()}>
<ActionIcon
onClick={selectionMode ? onPageBreak : onPageBreakAll}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "orange" : undefined}
size="lg"
>
<InsertPageBreakIcon />
</ActionIcon>
</Tooltip>
<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' }} />

View File

@ -95,7 +95,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Request thumbnail generation if we have the original file // Request thumbnail generation if we have the original file
if (originalFile) { if (originalFile) {
const pageNumber = page.originalPageNumber; const pageNumber = page.originalPageNumber;
requestThumbnail(page.id, originalFile, pageNumber) requestThumbnail(page.id, originalFile, pageNumber)
.then(thumbnail => { .then(thumbnail => {
if (!isCancelled && thumbnail) { if (!isCancelled && thumbnail) {
@ -116,14 +116,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
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 +131,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}, },
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);
@ -155,7 +155,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}); });
element.style.cursor = 'grab'; element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({ const dropCleanup = dropTargetForElements({
element, element,
getData: () => ({ getData: () => ({
@ -164,7 +164,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}), }),
onDrop: ({ source }) => {} onDrop: ({ source }) => {}
}); });
(element as any).__dragCleanup = () => { (element as any).__dragCleanup = () => {
dragCleanup(); dragCleanup();
dropCleanup(); dropCleanup();
@ -202,11 +202,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const handleSplit = useCallback((e: React.MouseEvent) => { const handleSplit = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// Create a command to toggle split at this position // Create a command to toggle split at this position
const command = createSplitCommand(index); const command = createSplitCommand(index);
onExecuteCommand(command); onExecuteCommand(command);
const hasSplit = splitPositions.has(index); const hasSplit = splitPositions.has(index);
const action = hasSplit ? 'removed' : 'added'; const action = hasSplit ? 'removed' : 'added';
onSetStatus(`Split marker ${action} after position ${index + 1}`); onSetStatus(`Split marker ${action} after position ${index + 1}`);
@ -215,7 +215,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Handle click vs drag differentiation // Handle click vs drag differentiation
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!selectionMode) return; if (!selectionMode) return;
setIsMouseDown(true); setIsMouseDown(true);
setMouseStartPos({ x: e.clientX, y: e.clientY }); setMouseStartPos({ x: e.clientX, y: e.clientY });
}, [selectionMode]); }, [selectionMode]);
@ -292,7 +292,10 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
boxShadow: '0 2px 4px rgba(0,0,0,0.1)', boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto' pointerEvents: 'auto'
}} }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => {
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
onMouseUp={(e) => e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()}
onDragStart={(e) => { onDragStart={(e) => {
e.preventDefault(); e.preventDefault();
@ -302,9 +305,10 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
<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>
)} )}
@ -323,7 +327,14 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
{thumbnailUrl ? ( {page.isBlankPage ? (
<div style={{
width: '100%',
height: '100%',
backgroundColor: 'white',
borderRadius: 4
}}></div>
) : thumbnailUrl ? (
<img <img
src={thumbnailUrl} src={thumbnailUrl}
alt={`Page ${page.pageNumber}`} alt={`Page ${page.pageNumber}`}
@ -354,7 +365,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
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,
@ -486,4 +497,4 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
); );
}; };
export default PageThumbnail; export default PageThumbnail;

View File

@ -396,6 +396,147 @@ export class SplitAllCommand extends DOMCommand {
} }
} }
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);
this.setSelectedPages([]);
}
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;
constructor(
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc) return;
// 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);
this.setSelectedPages([]);
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert page breaks after all pages`;
}
}
// Simple undo manager for DOM commands // Simple undo manager for DOM commands
export class UndoManager { export class UndoManager {
private undoStack: DOMCommand[] = []; private undoStack: DOMCommand[] = [];

View File

@ -121,10 +121,10 @@ export class DocumentManipulationService {
*/ */
private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number { private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number {
const img = pageElement.querySelector('img'); const img = pageElement.querySelector('img');
if (img && img.style.rotate) { if (img && img.style.transform) {
// Parse rotation from DOM (e.g., "90deg" -> 90) // Parse rotation from transform property (e.g., "rotate(90deg)" -> 90)
const rotationMatch = img.style.rotate.match(/-?\d+/); const rotationMatch = img.style.transform.match(/rotate\((-?\d+)deg\)/);
const domRotation = rotationMatch ? parseInt(rotationMatch[0]) : 0; const domRotation = rotationMatch ? parseInt(rotationMatch[1]) : 0;
console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`); console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`);
return domRotation; return domRotation;
@ -146,7 +146,7 @@ export class DocumentManipulationService {
const img = pageElement.querySelector('img'); const img = pageElement.querySelector('img');
if (img) { if (img) {
// Reset rotation to match document state // Reset rotation to match document state
img.style.rotate = `${page.rotation}deg`; img.style.transform = `rotate(${page.rotation}deg)`;
} }
} }
}); });

View File

@ -50,19 +50,29 @@ export class PDFExportService {
const newDoc = await PDFLibDocument.create(); const newDoc = await PDFLibDocument.create();
for (const page of pages) { for (const page of pages) {
// Get the original page from source document using originalPageNumber if (page.isBlankPage || page.originalPageNumber === -1) {
const sourcePageIndex = page.originalPageNumber - 1; // Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page // Apply rotation if needed
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 {
// Get the original page from source document using originalPageNumber
const sourcePageIndex = page.originalPageNumber - 1;
newDoc.addPage(copiedPage); 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);
}
} }
} }

View File

@ -6,6 +6,7 @@ export interface PDFPage {
rotation: number; rotation: number;
selected: boolean; selected: boolean;
splitAfter?: boolean; splitAfter?: boolean;
isBlankPage?: boolean;
} }
export interface PDFDocument { export interface PDFDocument {