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, 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"; 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(null); const [isBusy, setIsBusy] = useState(false); const [error, setError] = useState(null); const [workspaces, setWorkspaces] = useState([]); const [activeWorkspaceId, setActiveWorkspaceId] = useState( null, ); const [workspaceName, setWorkspaceName] = useState(""); 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 { 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 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 => { 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: ( <>

This workspace has unsaved changes.

Do you want to save it before resetting?

), 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: ( <>

Delete the saved workspace {name} from this browser?

The currently open in-memory document will not be closed, but the saved workspace entry will be removed.

), 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: (

Delete {pageLabel} from the current workspace?

), 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, ) => { 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 idsToDelete = [...selectedPageIds]; openActionDialog({ title: idsToDelete.length === 1 ? "Delete selected page?" : "Delete selected pages?", content: (

Delete{" "} {idsToDelete.length === 1 ? "1 selected page" : `${idsToDelete.length} selected pages`} {" "} from the current workspace?

), actions: [ { label: "Cancel", variant: "secondary", onClick: closeActionDialog, }, { label: idsToDelete.length === 1 ? "Delete page" : "Delete pages", variant: "danger", autoFocus: true, onClick: () => { closeActionDialog(); performDeleteSelected(idsToDelete); }, }, ], }); }; const performDeleteSelected = (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, }, }), ); }; 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 closeActionDialog = () => { setActionDialog(null); }; const openActionDialog = (dialog: { title: string; content: React.ReactNode; actions: ActionDialogAction[]; }) => { setActionDialog(dialog); }; 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 selectedSet = new Set(selectedPageIds); const selectedPages = pages.filter((page) => selectedSet.has(page.id)); 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 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 ( setHelpOpen(true)}> { setWorkspaceName(value); setWorkspaceDirty(true); }} onSaveWorkspace={handleSaveWorkspace} onLoadWorkspace={handleLoadWorkspace} onDeleteWorkspace={handleDeleteWorkspace} onRefreshWorkspaces={refreshWorkspaces} onResetWorkspace={handleResetWorkspace} onUndo={handleUndo} onRedo={handleRedo} /> {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} /> {actionDialog?.content} setHelpOpen(false)} />
); }; export default App;