Files
pdf-tools/src/App.tsx

1287 lines
34 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import Layout from './components/Layout';
import FileLoader from './components/FileLoader';
import ReorderPanel from './components/ReorderPanel';
import ActionsPanel from './components/ActionsPanel';
import PagePreviewModal from './components/PagePreviewModal';
import WorkspacePanel from './components/WorkspacePanel';
import ActionDialog, {
type ActionDialogAction,
} from './components/ActionDialog';
import HelpDialog from './components/HelpDialog';
import { PDFDocument } from 'pdf-lib';
import type {
StoredWorkspace,
WorkspaceSummary,
} from './workspace/workspaceTypes';
import {
createInitialPageRefs,
createPageRefId,
createPdfId,
createWorkspaceId,
defaultWorkspaceNameFromPdfName,
normalizeRotation,
useWorkspaceState,
} from './workspace/useWorkspaceState';
import {
deleteWorkspaceFromIndexedDb,
listWorkspaces,
loadWorkspaceFromIndexedDb,
saveWorkspaceToIndexedDb,
} from './workspace/workspaceDb';
import type { PageRef, PdfFile } from './pdf/pdfTypes';
import {
loadPdfFromFile,
mergePdfFiles,
splitIntoSinglePages,
exportPages,
} from './pdf/pdfService';
import { usePdfThumbnails } from './pdf/usePdfThumbnails';
import { usePdfGeneratedOutputs } from './hooks/usePdfGeneratedOutputs';
import {
createSelectionPdfName,
createSelectionWorkspaceName,
getSelectedPagesInVisualOrder,
} from './workspace/workspaceSelection';
function isEditableKeyboardTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return (
target.isContentEditable ||
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select'
);
}
const App: React.FC = () => {
const [actionDialog, setActionDialog] = useState<{
title: string;
content: React.ReactNode;
actions: ActionDialogAction[];
} | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const [pdf, setPdf] = useState<PdfFile | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(
null
);
const [workspaceName, setWorkspaceName] = useState('');
const [previewPageId, setPreviewPageId] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [showMergeOptions, setShowMergeOptions] = useState(false);
const [mergeMode, setMergeMode] = useState<
'overwrite' | 'append' | 'insertAt'
>('append');
const [mergeInsertAt, setMergeInsertAt] = useState<string>('');
const {
splitDownloads,
subsetDownload,
exportDownload,
replaceSplitResults,
replaceSubsetResult,
replaceExportResult,
clearAllResults: clearGeneratedOutputs,
} = usePdfGeneratedOutputs();
const handleWorkspaceContentChanged = useCallback(() => {
clearGeneratedOutputs();
}, [clearGeneratedOutputs]);
const {
pages,
selectedPageIds,
setSelectedPageIds,
lastSelectedVisualIndex,
setLastSelectedVisualIndex,
workspaceDirty,
setWorkspaceDirty,
workspaceMessage,
setWorkspaceMessage,
workspaceHistory,
redoHistory,
getCurrentCommandState,
createWorkspaceCommand,
executeWorkspaceCommand,
handleUndo,
handleRedo,
replaceWorkspaceState,
resetWorkspaceState: resetWorkspaceCommandState,
} = useWorkspaceState({ onContentChanged: handleWorkspaceContentChanged });
const handleThumbnailError = useCallback(
(message: string, thrown: unknown) => {
console.error(thrown);
setError(message);
},
[]
);
const { thumbnails: reorderThumbnails, clearThumbnailCache } =
usePdfThumbnails({
pdf,
pages,
onError: handleThumbnailError,
});
const closeActionDialog = useCallback(() => {
setActionDialog(null);
}, []);
const openActionDialog = useCallback(
(dialog: {
title: string;
content: React.ReactNode;
actions: ActionDialogAction[];
}) => {
setActionDialog(dialog);
},
[]
);
const refreshWorkspaces = async () => {
try {
const summaries = await listWorkspaces();
setWorkspaces(summaries);
} catch (e) {
console.error(e);
setError('Failed to read saved workspaces from browser storage.');
}
};
useEffect(() => {
void refreshWorkspaces();
}, []);
const resetWorkspaceState = () => {
setPdf(null);
setActiveWorkspaceId(null);
setWorkspaceName('');
resetWorkspaceCommandState();
clearGeneratedOutputs();
clearThumbnailCache();
setPreviewPageId(null);
};
const handleSaveWorkspace = async (): Promise<boolean> => {
if (!pdf || pages.length === 0) return false;
setError(null);
const now = new Date().toISOString();
const name =
workspaceName.trim() || defaultWorkspaceNameFromPdfName(pdf.name);
const workspaceId = activeWorkspaceId ?? createWorkspaceId();
const existing = workspaces.find(
(workspace) => workspace.id === workspaceId
);
const workspace: StoredWorkspace = {
schemaVersion: 1,
id: workspaceId,
name,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
pdfId: pdf.id,
pdfName: pdf.name,
sourcePageCount: pdf.pageCount,
pages,
selectedPageIds,
history: workspaceHistory,
redoHistory,
};
setIsBusy(true);
try {
await saveWorkspaceToIndexedDb({
workspace,
pdfArrayBuffer: pdf.arrayBuffer,
});
setActiveWorkspaceId(workspaceId);
setWorkspaceName(name);
setWorkspaceDirty(false);
setWorkspaceMessage(`Workspace "${name}" saved.`);
await refreshWorkspaces();
return true;
} catch (e) {
console.error(e);
setError(
'Failed to save workspace. The browser storage quota may be full.'
);
return false;
} finally {
setIsBusy(false);
}
};
const performResetWorkspace = () => {
resetWorkspaceState();
};
const handleResetWorkspace = () => {
if (!pdf) return;
if (!workspaceDirty) {
performResetWorkspace();
return;
}
openActionDialog({
title: 'Reset workspace?',
content: (
<>
<p style={{ marginTop: 0 }}>This workspace has unsaved changes.</p>
<p style={{ marginBottom: 0 }}>
Do you want to save it before resetting?
</p>
</>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: 'Reset without saving',
variant: 'danger',
onClick: () => {
closeActionDialog();
performResetWorkspace();
},
},
{
label: 'Save and reset',
variant: 'primary',
autoFocus: true,
onClick: async () => {
closeActionDialog();
const saved = await handleSaveWorkspace();
if (saved) {
performResetWorkspace();
}
},
},
],
});
};
const handleLoadWorkspace = async (workspaceId: string) => {
setError(null);
setIsBusy(true);
try {
const loaded = await loadWorkspaceFromIndexedDb(workspaceId);
if (!loaded) {
setError('Workspace not found.');
await refreshWorkspaces();
return;
}
clearGeneratedOutputs();
const doc = await PDFDocument.load(loaded.pdfArrayBuffer);
const loadedPdf: PdfFile = {
id: loaded.workspace.pdfId,
name: loaded.workspace.pdfName,
pageCount: doc.getPageCount(),
arrayBuffer: loaded.pdfArrayBuffer,
doc,
};
setPdf(loadedPdf);
replaceWorkspaceState({
pages: loaded.workspace.pages,
selectedPageIds: loaded.workspace.selectedPageIds ?? [],
lastSelectedVisualIndex: null,
history: loaded.workspace.history ?? [],
redoHistory: loaded.workspace.redoHistory ?? [],
dirty: false,
message: `Workspace "${loaded.workspace.name}" loaded.`,
});
setPreviewPageId(null);
clearThumbnailCache();
setActiveWorkspaceId(loaded.workspace.id);
setWorkspaceName(loaded.workspace.name);
} catch (e) {
console.error(e);
setError('Failed to load workspace from browser storage.');
} finally {
setIsBusy(false);
}
};
const handleDeleteWorkspace = (workspaceId: string) => {
const workspace = workspaces.find((item) => item.id === workspaceId);
const name = workspace?.name ?? 'this workspace';
openActionDialog({
title: 'Delete workspace?',
content: (
<>
<p style={{ marginTop: 0 }}>
Delete the saved workspace <strong>{name}</strong> from this
browser?
</p>
<p style={{ marginBottom: 0 }}>
The currently open in-memory document will not be closed, but the
saved workspace entry will be removed.
</p>
</>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: 'Delete workspace',
variant: 'danger',
autoFocus: true,
onClick: () => {
closeActionDialog();
void performDeleteWorkspace(workspaceId);
},
},
],
});
};
const performDeleteWorkspace = async (workspaceId: string) => {
setError(null);
try {
await deleteWorkspaceFromIndexedDb(workspaceId);
if (activeWorkspaceId === workspaceId) {
setActiveWorkspaceId(null);
setWorkspaceDirty(true);
setWorkspaceMessage(
'Saved workspace deleted. Current in-memory document remains open.'
);
}
await refreshWorkspaces();
} catch (e) {
console.error(e);
setError('Failed to delete workspace.');
}
};
const loadFileAsNew = async (file: File) => {
setError(null);
resetWorkspaceState();
setIsBusy(true);
try {
const loaded = await loadPdfFromFile(file);
const initialPages = createInitialPageRefs(loaded.pageCount);
setPdf(loaded);
replaceWorkspaceState({
pages: initialPages,
selectedPageIds: [],
lastSelectedVisualIndex: null,
history: [],
redoHistory: [],
dirty: true,
message: null,
});
setWorkspaceName(defaultWorkspaceNameFromPdfName(loaded.name));
} catch (e) {
console.error(e);
setError('Failed to load PDF (see console).');
} finally {
setIsBusy(false);
}
};
const handleFileLoaded = (file: File) => {
if (!pdf || pages.length === 0) {
void loadFileAsNew(file);
} else {
setPendingFile(file);
setShowMergeOptions(true);
setMergeMode('append');
setMergeInsertAt(String(pages.length + 1));
}
};
const handleMergeCancel = () => {
setPendingFile(null);
setShowMergeOptions(false);
};
const handleMergeConfirm = async () => {
if (!pendingFile) return;
if (!pdf || mergeMode === 'overwrite') {
await loadFileAsNew(pendingFile);
setPendingFile(null);
setShowMergeOptions(false);
return;
}
setError(null);
setIsBusy(true);
try {
// 1) Materialize the current in-memory workspace (page refs + rotations)
const currentBlob = await exportPages(pdf, pages);
const currentArrayBuffer = await currentBlob.arrayBuffer();
const currentDoc = await PDFDocument.load(currentArrayBuffer);
const currentPdf: PdfFile = {
id: pdf.id,
name: pdf.name,
doc: currentDoc,
arrayBuffer: currentArrayBuffer,
pageCount: pages.length,
};
// 2) Load the new PDF
const newPdf = await loadPdfFromFile(pendingFile);
// 3) Determine insert position (0-based)
let insertAt = pages.length; // default: append at end
if (mergeMode === 'insertAt') {
const parsed = parseInt(mergeInsertAt, 10);
if (Number.isFinite(parsed)) {
insertAt = Math.min(Math.max(parsed - 1, 0), pages.length);
}
} else if (mergeMode === 'append') {
insertAt = pages.length;
}
// 4) Merge
const mergedPdf = await mergePdfFiles(currentPdf, newPdf, insertAt);
const mergedPages = createInitialPageRefs(mergedPdf.pageCount);
// 5) Reset state to the merged document
setPdf(mergedPdf);
replaceWorkspaceState({
pages: mergedPages,
selectedPageIds: [],
lastSelectedVisualIndex: null,
history: [],
redoHistory: [],
dirty: true,
message: null,
});
clearGeneratedOutputs();
clearThumbnailCache();
setPreviewPageId(null);
setWorkspaceName(defaultWorkspaceNameFromPdfName(mergedPdf.name));
setActiveWorkspaceId(null);
} catch (e) {
console.error(e);
setError('Failed to merge PDF (see console).');
} finally {
setIsBusy(false);
setPendingFile(null);
setShowMergeOptions(false);
}
};
useEffect(() => {
if (
previewPageId != null &&
!pages.some((page) => page.id === previewPageId)
) {
setPreviewPageId(null);
}
}, [previewPageId, pages]);
const hasPdf = !!pdf;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableKeyboardTarget(e.target)) return;
if (e.key === 'F1' || e.key === '?') {
e.preventDefault();
setHelpOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
// === UI interactions ===
const handleRotatePageClockwise = (pageId: string) => {
const before = getCurrentCommandState();
const afterPages = pages.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
: page
);
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'page.rotate',
label: 'Rotated page clockwise',
before,
after: {
...before,
pages: afterPages,
},
details: {
pageId,
degrees: 90,
},
})
);
};
const handleRotatePageCounterclockwise = (pageId: string) => {
const before = getCurrentCommandState();
const afterPages = pages.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
: page
);
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'page.rotate',
label: 'Rotated page counterclockwise',
before,
after: {
...before,
pages: afterPages,
},
details: {
pageId,
degrees: -90,
},
})
);
};
const handleDeletePage = (pageId: string) => {
const page = pages.find((item) => item.id === pageId);
const visualIndex = page ? pages.indexOf(page) : -1;
const pageLabel =
visualIndex >= 0 ? `page at position ${visualIndex + 1}` : 'this page';
openActionDialog({
title: 'Delete page?',
content: (
<p style={{ margin: 0 }}>
Delete <strong>{pageLabel}</strong> from the current workspace?
</p>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: 'Delete page',
variant: 'danger',
autoFocus: true,
onClick: () => {
closeActionDialog();
performDeletePage(pageId);
},
},
],
});
};
const performDeletePage = (pageId: string) => {
const before = getCurrentCommandState();
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'page.delete',
label: 'Deleted page',
before,
after: {
pages: pages.filter((page) => page.id !== pageId),
selectedPageIds: selectedPageIds.filter((id) => id !== pageId),
lastSelectedVisualIndex: null,
},
details: {
pageId,
},
})
);
};
const handleReorder = (newPages: PageRef[]) => {
const before = getCurrentCommandState();
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'pages.reorder',
label: 'Reordered pages',
before,
after: {
...before,
pages: newPages,
},
details: {
pageCount: newPages.length,
},
})
);
};
const handleToggleSelect = (
pageId: string,
visualIndex: number,
e: React.MouseEvent<HTMLButtonElement>
) => {
setSelectedPageIds((prev) => {
if (e.shiftKey && lastSelectedVisualIndex !== null && pages.length > 0) {
const from = Math.min(lastSelectedVisualIndex, visualIndex);
const to = Math.max(lastSelectedVisualIndex, visualIndex);
const rangeIds = pages.slice(from, to + 1).map((page) => page.id);
const set = new Set(prev);
rangeIds.forEach((id) => set.add(id));
return Array.from(set);
}
if (prev.includes(pageId)) {
return prev.filter((id) => id !== pageId);
}
return [...prev, pageId];
});
setLastSelectedVisualIndex(visualIndex);
};
const handleSelectAll = () => {
setSelectedPageIds(pages.map((page) => page.id));
setLastSelectedVisualIndex(null);
};
const handleClearSelection = () => {
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
};
const performDeleteSelected = useCallback(
(pageIdsToDelete: string[]) => {
if (pageIdsToDelete.length === 0) return;
const before = getCurrentCommandState();
const selectedSet = new Set(pageIdsToDelete);
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'pages.delete',
label:
pageIdsToDelete.length === 1
? 'Deleted selected page'
: `Deleted ${pageIdsToDelete.length} selected pages`,
before,
after: {
pages: pages.filter((page) => !selectedSet.has(page.id)),
selectedPageIds: [],
lastSelectedVisualIndex: null,
},
details: {
count: pageIdsToDelete.length,
},
})
);
},
[
createWorkspaceCommand,
executeWorkspaceCommand,
getCurrentCommandState,
pages,
]
);
const handleDeleteSelected = useCallback(() => {
if (selectedPageIds.length === 0) return;
const idsToDelete = [...selectedPageIds];
openActionDialog({
title:
idsToDelete.length === 1
? 'Delete selected page?'
: 'Delete selected pages?',
content: (
<p style={{ margin: 0 }}>
Delete{' '}
<strong>
{idsToDelete.length === 1
? '1 selected page'
: `${idsToDelete.length} selected pages`}
</strong>{' '}
from the current workspace?
</p>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: idsToDelete.length === 1 ? 'Delete page' : 'Delete pages',
variant: 'danger',
autoFocus: true,
onClick: () => {
closeActionDialog();
performDeleteSelected(idsToDelete);
},
},
],
});
}, [
closeActionDialog,
openActionDialog,
performDeleteSelected,
selectedPageIds,
]);
const handleCopyPagesToSlot = (pageIds: string[], insertSlot: number) => {
if (!pdf || pageIds.length === 0) return;
const pageIdSet = new Set(pageIds);
// Copy in current visual order, not in arbitrary selectedPageIds order.
const sourcePages = pages.filter((page) => pageIdSet.has(page.id));
if (sourcePages.length === 0) return;
const copiedPages: PageRef[] = sourcePages.map((page) => ({
...page,
id: createPageRefId(),
}));
const clampedSlot = Math.min(Math.max(insertSlot, 0), pages.length);
const afterPages = [
...pages.slice(0, clampedSlot),
...copiedPages,
...pages.slice(clampedSlot),
];
const before = getCurrentCommandState();
executeWorkspaceCommand(
createWorkspaceCommand({
type: 'pages.copy',
label:
copiedPages.length === 1
? 'Copied page'
: `Copied ${copiedPages.length} pages`,
before,
after: {
pages: afterPages,
selectedPageIds: copiedPages.map((page) => page.id),
lastSelectedVisualIndex: null,
},
details: {
count: copiedPages.length,
insertSlot: clampedSlot,
},
})
);
};
const handleOpenPreview = (pageId: string) => {
setPreviewPageId(pageId);
};
const handleClosePreview = () => {
setPreviewPageId(null);
};
const handlePreviewPrevious = () => {
setPreviewPageId((current) => {
if (current == null || pages.length === 0) return current;
const visualIndex = pages.findIndex((page) => page.id === current);
if (visualIndex <= 0) return current;
return pages[visualIndex - 1].id;
});
};
const handlePreviewNext = () => {
setPreviewPageId((current) => {
if (current == null || pages.length === 0) return current;
const visualIndex = pages.findIndex((page) => page.id === current);
if (visualIndex < 0 || visualIndex >= pages.length - 1) return current;
return pages[visualIndex + 1].id;
});
};
useEffect(() => {
if (!hasPdf || previewPageId !== null) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (isEditableKeyboardTarget(e.target)) return;
const key = e.key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault();
if (e.shiftKey) {
handleRedo();
} else {
handleUndo();
}
return;
}
if ((e.ctrlKey || e.metaKey) && key === 'y') {
e.preventDefault();
handleRedo();
return;
}
if ((e.ctrlKey || e.metaKey) && key === 'a') {
e.preventDefault();
setSelectedPageIds(pages.map((page) => page.id));
setLastSelectedVisualIndex(null);
return;
}
if (
(e.key === 'Delete' || e.key === 'Backspace') &&
selectedPageIds.length > 0
) {
e.preventDefault();
handleDeleteSelected();
return;
}
if (e.key === 'Escape' && selectedPageIds.length > 0) {
e.preventDefault();
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [
hasPdf,
previewPageId,
pages,
selectedPageIds,
workspaceHistory,
redoHistory,
handleUndo,
handleRedo,
handleDeleteSelected,
setSelectedPageIds,
setLastSelectedVisualIndex,
]);
const handleSplit = async () => {
if (!pdf) return;
setError(null);
setIsBusy(true);
try {
const result = await splitIntoSinglePages(pdf);
replaceSplitResults(result);
} catch (e) {
console.error(e);
setError('Error while splitting PDF (see console).');
} finally {
setIsBusy(false);
}
};
const handleExtractSelected = async () => {
if (!pdf || selectedPageIds.length === 0) return;
setError(null);
setIsBusy(true);
try {
const selectedPages = getSelectedPagesInVisualOrder(
pages,
selectedPageIds
);
if (selectedPages.length === 0) return;
const blob = await exportPages(pdf, selectedPages);
const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_selected.pdf`;
replaceSubsetResult(blob, filename);
} catch (e) {
console.error(e);
setError('Error while extracting selected pages (see console).');
} finally {
setIsBusy(false);
}
};
const performOpenSelectionAsWorkspace = async () => {
if (!pdf || selectedPageIds.length === 0) return;
const selectedPages = getSelectedPagesInVisualOrder(pages, selectedPageIds);
if (selectedPages.length === 0) return;
setError(null);
setIsBusy(true);
try {
const selectedPageCount = selectedPages.length;
const blob = await exportPages(pdf, selectedPages);
const arrayBuffer = await blob.arrayBuffer();
const doc = await PDFDocument.load(arrayBuffer);
const pdfName = createSelectionPdfName(pdf.name, selectedPageCount);
const workspaceName = createSelectionWorkspaceName(
pdf.name,
selectedPageCount
);
const extractedPdf: PdfFile = {
id: createPdfId(),
name: pdfName,
doc,
pageCount: doc.getPageCount(),
arrayBuffer,
};
setPdf(extractedPdf);
replaceWorkspaceState({
pages: createInitialPageRefs(extractedPdf.pageCount),
selectedPageIds: [],
lastSelectedVisualIndex: null,
history: [],
redoHistory: [],
dirty: true,
message: `Created a new workspace from ${selectedPageCount} selected ${
selectedPageCount === 1 ? 'page' : 'pages'
}.`,
});
setActiveWorkspaceId(null);
setWorkspaceName(workspaceName);
setPreviewPageId(null);
clearGeneratedOutputs();
clearThumbnailCache();
} catch (e) {
console.error(e);
setError('Error while opening selection as a new workspace.');
} finally {
setIsBusy(false);
}
};
const handleOpenSelectionAsWorkspace = () => {
if (!pdf || selectedPageIds.length === 0) return;
const selectedPages = getSelectedPagesInVisualOrder(pages, selectedPageIds);
if (selectedPages.length === 0) return;
if (!workspaceDirty) {
void performOpenSelectionAsWorkspace();
return;
}
openActionDialog({
title: 'Open selection as new workspace?',
content: (
<>
<p style={{ marginTop: 0 }}>
This will replace the current in-memory workspace with a new
workspace built from {selectedPages.length}{' '}
{selectedPages.length === 1 ? 'selected page' : 'selected pages'}.
</p>
<p style={{ marginBottom: 0 }}>
The current workspace has unsaved changes. Do you want to save it
before opening the selection as a new workspace?
</p>
</>
),
actions: [
{
label: 'Cancel',
variant: 'secondary',
onClick: closeActionDialog,
},
{
label: 'Open without saving',
variant: 'danger',
onClick: () => {
closeActionDialog();
void performOpenSelectionAsWorkspace();
},
},
{
label: 'Save and open',
variant: 'primary',
autoFocus: true,
onClick: async () => {
closeActionDialog();
const saved = await handleSaveWorkspace();
if (saved) {
await performOpenSelectionAsWorkspace();
}
},
},
],
});
};
const handleExportReordered = async () => {
if (!pdf || pages.length === 0) return;
setError(null);
setIsBusy(true);
try {
const blob = await exportPages(pdf, pages);
const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_reordered.pdf`;
replaceExportResult(blob, filename);
} catch (e) {
console.error(e);
setError('Error while exporting reordered PDF (see console).');
} finally {
setIsBusy(false);
}
};
const previewVisualIndex =
previewPageId != null
? pages.findIndex((page) => page.id === previewPageId)
: -1;
const previewPage =
previewVisualIndex >= 0 ? pages[previewVisualIndex] : null;
const canPreviewPrevious = previewVisualIndex > 0;
const canPreviewNext =
previewVisualIndex >= 0 && previewVisualIndex < pages.length - 1;
return (
<Layout onOpenHelp={() => setHelpOpen(true)}>
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
<WorkspacePanel
hasPdf={hasPdf}
isBusy={isBusy}
activeWorkspaceId={activeWorkspaceId}
workspaceName={workspaceName}
workspaceDirty={workspaceDirty}
workspaceMessage={workspaceMessage}
workspaces={workspaces}
history={workspaceHistory}
redoHistory={redoHistory}
onWorkspaceNameChange={(value) => {
setWorkspaceName(value);
setWorkspaceDirty(true);
}}
onSaveWorkspace={handleSaveWorkspace}
onLoadWorkspace={handleLoadWorkspace}
onDeleteWorkspace={handleDeleteWorkspace}
onRefreshWorkspaces={refreshWorkspaces}
onResetWorkspace={handleResetWorkspace}
onUndo={handleUndo}
onRedo={handleRedo}
/>
{showMergeOptions && pendingFile && pdf && pages.length > 0 && (
<div
className="card"
style={{ border: '1px solid #bfdbfe', background: '#eff6ff' }}
>
<h2>Open file: merge or replace?</h2>
<p style={{ fontSize: '0.85rem', color: '#374151' }}>
You already have <strong>{pdf.name}</strong> with {pages.length}{' '}
pages open. What should happen with{' '}
<strong>{pendingFile.name}</strong>?
</p>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
marginTop: '0.5rem',
fontSize: '0.9rem',
}}
>
<label
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<input
type="radio"
name="mergeMode"
value="overwrite"
checked={mergeMode === 'overwrite'}
onChange={() => setMergeMode('overwrite')}
/>
<span>Replace current document</span>
</label>
<label
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<input
type="radio"
name="mergeMode"
value="append"
checked={mergeMode === 'append'}
onChange={() => setMergeMode('append')}
/>
<span>Merge and append pages at the end</span>
</label>
<label
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
<input
type="radio"
name="mergeMode"
value="insertAt"
checked={mergeMode === 'insertAt'}
onChange={() => setMergeMode('insertAt')}
/>
<span>
Merge and insert starting at position{' '}
<input
type="number"
min={1}
max={pages.length + 1}
value={mergeInsertAt}
onChange={(e) => setMergeInsertAt(e.target.value)}
style={{
width: '4rem',
padding: '0.15rem 0.3rem',
fontSize: '0.85rem',
}}
/>{' '}
<span style={{ color: '#6b7280' }}>
(1 = before first page, {pages.length + 1} = after last page)
</span>
</span>
</label>
</div>
<div className="button-row" style={{ marginTop: '0.75rem' }}>
<button
className="secondary"
type="button"
onClick={handleMergeCancel}
disabled={isBusy}
>
Cancel
</button>
<button
className="primary"
type="button"
onClick={handleMergeConfirm}
disabled={isBusy}
>
{isBusy ? 'Working…' : 'Continue'}
</button>
</div>
</div>
)}
<ReorderPanel
pages={pages}
thumbnails={reorderThumbnails}
isBusy={isBusy}
hasPdf={hasPdf}
selectedPageIds={selectedPageIds}
onRotateClockwise={handleRotatePageClockwise}
onRotateCounterclockwise={handleRotatePageCounterclockwise}
onDelete={handleDeletePage}
onReorder={handleReorder}
onCopyPagesToSlot={handleCopyPagesToSlot}
onToggleSelect={handleToggleSelect}
onSelectAll={handleSelectAll}
onOpenPreview={handleOpenPreview}
onClearSelection={handleClearSelection}
onDeleteSelected={handleDeleteSelected}
/>
<ActionsPanel
hasPdf={hasPdf}
isBusy={isBusy}
selectedCount={selectedPageIds.length}
onSplit={handleSplit}
onExtractSelected={handleExtractSelected}
onOpenSelectionAsWorkspace={handleOpenSelectionAsWorkspace}
onExportReordered={handleExportReordered}
splitDownloads={splitDownloads}
subsetDownload={subsetDownload}
exportDownload={exportDownload}
/>
{error && (
<div
className="card"
style={{ border: '1px solid #fecaca', background: '#fef2f2' }}
>
<strong>Error:</strong> {error}
</div>
)}
<PagePreviewModal
isOpen={previewPage !== null}
pdf={pdf}
pageIndex={previewPage?.sourcePageIndex ?? null}
rotation={previewPage?.rotation ?? 0}
visualIndex={previewVisualIndex >= 0 ? previewVisualIndex : null}
totalPages={pages.length}
canGoPrevious={canPreviewPrevious}
canGoNext={canPreviewNext}
onPrevious={handlePreviewPrevious}
onNext={handlePreviewNext}
onClose={handleClosePreview}
/>
<ActionDialog
open={actionDialog !== null}
title={actionDialog?.title ?? ''}
actions={actionDialog?.actions ?? []}
onClose={closeActionDialog}
>
{actionDialog?.content}
</ActionDialog>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</Layout>
);
};
export default App;