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(null); const [isBusy, setIsBusy] = useState(false); const [error, setError] = useState(null); const [pages, setPages] = useState([]); const [reorderThumbnails, setReorderThumbnails] = useState>({}); const [selectedPageIds, setSelectedPageIds] = useState([]); const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState(null); const [splitResults, setSplitResults] = useState([]); const [subsetUrl, setSubsetUrl] = useState(null); const [subsetFilename, setSubsetFilename] = useState(null); const [exportUrl, setExportUrl] = useState(null); const [exportFilename, setExportFilename] = useState(null); const [previewPageId, setPreviewPageId] = useState(null); const [pendingFile, setPendingFile] = useState(null); const [showMergeOptions, setShowMergeOptions] = useState(false); const [mergeMode, setMergeMode] = useState<'overwrite' | 'append' | 'insertAt'>('append'); const [mergeInsertAt, setMergeInsertAt] = useState(''); const thumbnailCacheRef = useRef>(new Map()); const latestPagesRef = useRef([]); const previousPageRotationsRef = useRef>(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 = {}; 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 = {}; 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(); 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 = {}; 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 = {}; 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 ) => { 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 = {}; 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 ( {showMergeOptions && pendingFile && pdf && pages.length > 0 && (

Open file: merge or replace?

You already have {pdf.name} with {pages.length}{' '} pages open. What should happen with{' '} {pendingFile.name}?

)} {error && (
Error: {error}
)} = 0 ? previewVisualIndex : null} totalPages={pages.length} canGoPrevious={canPreviewPrevious} canGoNext={canPreviewNext} onPrevious={handlePreviewPrevious} onNext={handlePreviewNext} onClose={handleClosePreview} />
); }; export default App;