Files
pdf-tools/src/App.tsx

865 lines
25 KiB
TypeScript

import React, { useEffect, useRef, 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 { PDFDocument } from 'pdf-lib';
import type { PageRef, PdfFile, SplitResult } from './pdf/pdfTypes';
import {
loadPdfFromFile,
mergePdfFiles,
splitIntoSinglePages,
exportPages,
} from './pdf/pdfService';
import {
generateThumbnailsProgressive,
generateThumbnailsWithRotationsProgressive,
} from './pdf/pdfThumbnailService';
const THUMBNAIL_MAX_HEIGHT = 150;
const THUMBNAIL_MAX_WIDTH = 140;
const THUMBNAIL_CONCURRENCY = 3;
function createPageRefId(): string {
return Math.random().toString(36).slice(2);
}
function createInitialPageRefs(pageCount: number): PageRef[] {
return Array.from({ length: pageCount }, (_, sourcePageIndex) => ({
id: createPageRefId(),
sourcePageIndex,
rotation: 0,
}));
}
function normalizeRotation(rotation: number | undefined): number {
return (((rotation ?? 0) % 360) + 360) % 360;
}
function thumbnailCacheKey(
pdfId: string,
sourcePageIndex: number,
rotation: number
): string {
return [
pdfId,
sourcePageIndex,
normalizeRotation(rotation),
THUMBNAIL_MAX_WIDTH,
THUMBNAIL_MAX_HEIGHT,
].join(':');
}
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 [pdf, setPdf] = useState<PdfFile | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pages, setPages] = useState<PageRef[]>([]);
const [reorderThumbnails, setReorderThumbnails] = useState<Record<string, string>>({});
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState<number | null>(null);
const [splitResults, setSplitResults] = useState<SplitResult[]>([]);
const [subsetUrl, setSubsetUrl] = useState<string | null>(null);
const [subsetFilename, setSubsetFilename] = useState<string | null>(null);
const [exportUrl, setExportUrl] = useState<string | null>(null);
const [exportFilename, setExportFilename] = useState<string | null>(null);
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 thumbnailCacheRef = useRef<Map<string, string>>(new Map());
const latestPagesRef = useRef<PageRef[]>([]);
const previousPageRotationsRef = useRef<Map<string, number>>(new Map());
const resetGeneratedUrls = () => {
if (subsetUrl) {
URL.revokeObjectURL(subsetUrl);
setSubsetUrl(null);
setSubsetFilename(null);
}
if (exportUrl) {
URL.revokeObjectURL(exportUrl);
setExportUrl(null);
setExportFilename(null);
}
};
const resetWorkspaceState = () => {
setSplitResults([]);
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
resetGeneratedUrls();
setReorderThumbnails({});
thumbnailCacheRef.current.clear();
previousPageRotationsRef.current.clear();
latestPagesRef.current = [];
setPages([]);
setPreviewPageId(null);
};
const loadFileAsNew = async (file: File) => {
setError(null);
resetWorkspaceState();
setIsBusy(true);
try {
const loaded = await loadPdfFromFile(file);
const initialPages = createInitialPageRefs(loaded.pageCount);
setPdf(loaded);
setPages(initialPages);
latestPagesRef.current = initialPages;
} 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);
setPages(mergedPages);
latestPagesRef.current = mergedPages;
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
setSplitResults([]);
resetGeneratedUrls();
setReorderThumbnails({});
thumbnailCacheRef.current.clear();
previousPageRotationsRef.current.clear();
setPreviewPageId(null);
} catch (e) {
console.error(e);
setError('Failed to merge PDF (see console).');
} finally {
setIsBusy(false);
setPendingFile(null);
setShowMergeOptions(false);
}
};
useEffect(() => {
latestPagesRef.current = pages;
}, [pages]);
useEffect(() => {
if (!pdf) {
setReorderThumbnails({});
thumbnailCacheRef.current.clear();
previousPageRotationsRef.current.clear();
return;
}
const controller = new AbortController();
latestPagesRef.current = pages;
thumbnailCacheRef.current.clear();
previousPageRotationsRef.current = new Map(
pages.map((page) => [page.id, normalizeRotation(page.rotation)])
);
setReorderThumbnails({});
void generateThumbnailsProgressive(pdf.arrayBuffer, {
maxHeight: THUMBNAIL_MAX_HEIGHT,
maxWidth: THUMBNAIL_MAX_WIDTH,
concurrency: THUMBNAIL_CONCURRENCY,
signal: controller.signal,
onThumbnail: ({ pageIndex, dataUrl }) => {
if (controller.signal.aborted) return;
thumbnailCacheRef.current.set(
thumbnailCacheKey(pdf.id, pageIndex, 0),
dataUrl
);
const currentPages = latestPagesRef.current;
const updates: Record<string, string> = {};
for (const page of currentPages) {
if (
page.sourcePageIndex === pageIndex &&
normalizeRotation(page.rotation) === 0
) {
updates[page.id] = dataUrl;
}
}
if (Object.keys(updates).length === 0) return;
setReorderThumbnails((prev) => ({
...prev,
...updates,
}));
},
}).catch((e) => {
if (!controller.signal.aborted) {
console.error(e);
setError('Failed to generate thumbnails (see console).');
}
});
return () => {
controller.abort();
};
}, [pdf]);
useEffect(() => {
if (!pdf) {
previousPageRotationsRef.current.clear();
return;
}
const previousRotations = previousPageRotationsRef.current;
const changedPages = pages.filter(
(page) =>
normalizeRotation(previousRotations.get(page.id)) !==
normalizeRotation(page.rotation)
);
previousPageRotationsRef.current = new Map(
pages.map((page) => [page.id, normalizeRotation(page.rotation)])
);
if (changedPages.length === 0) return;
const cachedUpdates: Record<string, string> = {};
const pagesToRender: PageRef[] = [];
for (const page of changedPages) {
const rotation = normalizeRotation(page.rotation);
const cached = thumbnailCacheRef.current.get(
thumbnailCacheKey(pdf.id, page.sourcePageIndex, rotation)
);
if (cached) {
cachedUpdates[page.id] = cached;
} else {
pagesToRender.push(page);
}
}
if (Object.keys(cachedUpdates).length > 0) {
setReorderThumbnails((prev) => ({
...prev,
...cachedUpdates,
}));
}
if (pagesToRender.length === 0) return;
const controller = new AbortController();
const groups = new Map<number, PageRef[]>();
for (const page of pagesToRender) {
const rotation = normalizeRotation(page.rotation);
const group = groups.get(rotation) ?? [];
group.push(page);
groups.set(rotation, group);
}
const renderGroups = async () => {
for (const [rotation, groupPages] of groups) {
if (controller.signal.aborted) return;
const pageIndices = Array.from(
new Set(groupPages.map((page) => page.sourcePageIndex))
);
const rotationsBySourcePage: Record<number, number> = {};
for (const pageIndex of pageIndices) {
rotationsBySourcePage[pageIndex] = rotation;
}
await generateThumbnailsWithRotationsProgressive(
pdf.arrayBuffer,
rotationsBySourcePage,
{
maxHeight: THUMBNAIL_MAX_HEIGHT,
maxWidth: THUMBNAIL_MAX_WIDTH,
concurrency: Math.min(THUMBNAIL_CONCURRENCY, pageIndices.length),
pageIndices,
signal: controller.signal,
onThumbnail: ({ pageIndex, dataUrl }) => {
if (controller.signal.aborted) return;
thumbnailCacheRef.current.set(
thumbnailCacheKey(pdf.id, pageIndex, rotation),
dataUrl
);
const updates: Record<string, string> = {};
for (const page of latestPagesRef.current) {
if (
page.sourcePageIndex === pageIndex &&
normalizeRotation(page.rotation) === rotation
) {
updates[page.id] = dataUrl;
}
}
if (Object.keys(updates).length === 0) return;
setReorderThumbnails((prev) => ({
...prev,
...updates,
}));
},
}
);
}
};
void renderGroups().catch((e) => {
if (!controller.signal.aborted) {
console.error(e);
setError('Failed to generate rotated thumbnails (see console).');
}
});
return () => {
controller.abort();
};
}, [pdf, pages]);
useEffect(() => {
if (previewPageId != null && !pages.some((page) => page.id === previewPageId)) {
setPreviewPageId(null);
}
}, [previewPageId, pages]);
const hasPdf = !!pdf;
// === UI interactions ===
const handleRotatePageClockwise = (pageId: string) => {
setPages((prev) =>
prev.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 }
: page
)
);
};
const handleRotatePageCounterclockwise = (pageId: string) => {
setPages((prev) =>
prev.map((page) =>
page.id === pageId
? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 }
: page
)
);
};
const handleDeletePage = (pageId: string) => {
setPages((prev) => prev.filter((page) => page.id !== pageId));
setSelectedPageIds((prev) => prev.filter((id) => id !== pageId));
};
const handleReorder = (newPages: PageRef[]) => {
setPages(newPages);
};
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 handleDeleteSelected = () => {
if (selectedPageIds.length === 0) return;
const selectedSet = new Set(selectedPageIds);
setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
};
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 thumbnailUpdates: Record<string, string> = {};
sourcePages.forEach((sourcePage, index) => {
const copiedPage = copiedPages[index];
const thumbnail =
reorderThumbnails[sourcePage.id] ??
thumbnailCacheRef.current.get(
thumbnailCacheKey(
pdf.id,
sourcePage.sourcePageIndex,
sourcePage.rotation
)
);
if (thumbnail) {
thumbnailUpdates[copiedPage.id] = thumbnail;
}
});
setPages((prev) => {
const slot = Math.min(Math.max(clampedSlot, 0), prev.length);
return [
...prev.slice(0, slot),
...copiedPages,
...prev.slice(slot),
];
});
// Select the newly created copies.
setSelectedPageIds(copiedPages.map((page) => page.id));
setLastSelectedVisualIndex(null);
if (Object.keys(thumbnailUpdates).length > 0) {
setReorderThumbnails((prev) => ({
...prev,
...thumbnailUpdates,
}));
}
// Existing generated outputs no longer represent the current workspace.
setSplitResults([]);
resetGeneratedUrls();
};
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 === 'a') {
e.preventDefault();
setSelectedPageIds(pages.map((page) => page.id));
setLastSelectedVisualIndex(null);
return;
}
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPageIds.length > 0) {
e.preventDefault();
const selectedSet = new Set(selectedPageIds);
setPages((prev) => prev.filter((page) => !selectedSet.has(page.id)));
setSelectedPageIds([]);
setLastSelectedVisualIndex(null);
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]);
const handleSplit = async () => {
if (!pdf) return;
setError(null);
setIsBusy(true);
try {
const result = await splitIntoSinglePages(pdf);
setSplitResults(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);
if (subsetUrl) {
URL.revokeObjectURL(subsetUrl);
setSubsetUrl(null);
setSubsetFilename(null);
}
try {
const selectedSet = new Set(selectedPageIds);
const selectedPages = pages.filter((page) => selectedSet.has(page.id));
const blob = await exportPages(pdf, selectedPages);
const url = URL.createObjectURL(blob);
const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_selected.pdf`;
setSubsetUrl(url);
setSubsetFilename(filename);
} catch (e) {
console.error(e);
setError('Error while extracting selected pages (see console).');
} finally {
setIsBusy(false);
}
};
const handleExportReordered = async () => {
if (!pdf || pages.length === 0) return;
setError(null);
setIsBusy(true);
if (exportUrl) {
URL.revokeObjectURL(exportUrl);
setExportUrl(null);
setExportFilename(null);
}
try {
const blob = await exportPages(pdf, pages);
const url = URL.createObjectURL(blob);
const base = pdf.name.replace(/\.pdf$/i, '');
const filename = `${base}_reordered.pdf`;
setExportUrl(url);
setExportFilename(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>
<FileLoader pdf={pdf} onFileLoaded={handleFileLoaded} />
{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}
onExportReordered={handleExportReordered}
splitResults={splitResults}
subsetDownloadUrl={subsetUrl}
subsetFilename={subsetFilename}
exportDownloadUrl={exportUrl}
exportFilename={exportFilename}
/>
{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}
/>
</Layout>
);
};
export default App;