From 2461cf3d640d9a024824702c6ff666a4c9c52023 Mon Sep 17 00:00:00 2001 From: zemion Date: Sat, 16 May 2026 02:37:20 +0200 Subject: [PATCH] Roadmap, robust page refs, copy behaviour --- README | 77 +- src/App.tsx | 636 ++++++++++++----- src/components/PageList.tsx | 79 --- src/components/PagePreviewModal.tsx | 227 ++++-- src/components/ReorderPanel.tsx | 1002 +++++++++++++++++---------- src/pdf/pdfService.ts | 50 +- src/pdf/pdfThumbnailService.ts | 235 ++++--- src/pdf/pdfTypes.ts | 6 + src/styles.css | 20 - 9 files changed, 1588 insertions(+), 744 deletions(-) delete mode 100644 src/components/PageList.tsx diff --git a/README b/README index 7b4d68d..7dfd833 100644 --- a/README +++ b/README @@ -1 +1,76 @@ -empty \ No newline at end of file +# PDF Workbench + +Browser-only PDF tools for quick page-level editing. Processing happens completely locally in the browser; files are never uploaded to a server. + +## Current features + +- load a PDF in the browser +- generate page thumbnails progressively +- reorder pages via drag and drop +- select pages, including Shift range selection +- rotate pages clockwise/counter-clockwise +- delete pages from the working document +- preview pages in an overlay +- flip through preview pages with buttons or arrow keys +- merge a second PDF by replacing, appending, or inserting at a chosen position +- extract selected pages into a new PDF +- export the current reordered/rotated document +- split into single-page PDFs + +## Keyboard shortcuts + +- `CtrlA` / `⌘A`: select all pages +- `Delete` / `Backspace`: delete selected pages +- `Esc`: clear the current selection +- Preview overlay: `←` / `→` flip pages, `Esc` closes the overlay + +Keyboard shortcuts are ignored while typing in form fields. + +## Current implementation focus + +The project is currently optimized around page-level PDF work: split, merge, reorder, rotate, preview, select, delete, extract, and export. Deep content-stream editing is intentionally out of scope for now. + +## Roadmap + +### Milestone 1: Fast preview and thumbnails + +- [x] Remove unused `PageList` component +- [x] Bound thumbnail generation by width and height +- [x] Display thumbnails progressively +- [x] Add preview page flipping +- [x] Attach preview controls to the modal container +- [x] Add first keyboard shortcuts +- [x] Cache thumbnails by page and rotation +- [x] Regenerate only changed rotated thumbnails + +### Milestone 2: Real page workspace + +- [x] Introduce stable page references instead of only original page indices +- [x] Support duplicate selected pages +- [ ] Extract selection as a new active workspace +- [ ] Add command history as a foundation for undo/redo +- [ ] Add undo/redo +- [ ] Add grid/list view toggle + +### Milestone 3: Better merge and mobile handling + +- [ ] Add a full multi-file merge queue +- [ ] Support drag-and-drop of PDFs into the page grid at the hovered position +- [ ] Add custom long-press drag on mobile +- [ ] Consolidate actions into a toolbar + +### Milestone 4: Structural PDF editing + +- [ ] Metadata editing +- [ ] Crop pages +- [ ] Add tools directly in the preview overlay +- [ ] Read/fill/flatten forms +- [ ] Read bookmarks, then evaluate bookmark editing +- [ ] Read annotations, then evaluate annotation writing + +### Milestone 5: Export and power tools + +- [ ] Basic text extraction +- [ ] ZIP export for split results +- [ ] Optimize/compress MVP +- [ ] Carefully scoped encrypted PDF handling \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index bc1bfd2..2d717fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,38 +1,78 @@ -import React, { useEffect, useState } from 'react'; +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 type { PdfFile, SplitResult } from './pdf/pdfTypes'; +import { PDFDocument } from 'pdf-lib'; +import type { PageRef, PdfFile, SplitResult } from './pdf/pdfTypes'; import { loadPdfFromFile, mergePdfFiles, splitIntoSinglePages, - exportReordered, + exportPages, } from './pdf/pdfService'; import { - generateThumbnails, - generateThumbnailsWithRotations, + 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 [baseThumbnails, setBaseThumbnails] = useState(null); - const [reorderThumbnails, setReorderThumbnails] = useState( - null - ); + const [pages, setPages] = useState([]); + const [reorderThumbnails, setReorderThumbnails] = useState>({}); - const [order, setOrder] = useState([]); - const [rotations, setRotations] = useState>({}); - - const [selectedPages, setSelectedPages] = useState([]); - const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState< - number | null - >(null); + const [selectedPageIds, setSelectedPageIds] = useState([]); + const [lastSelectedVisualIndex, setLastSelectedVisualIndex] = useState(null); const [splitResults, setSplitResults] = useState([]); const [subsetUrl, setSubsetUrl] = useState(null); @@ -40,38 +80,56 @@ const App: React.FC = () => { const [exportUrl, setExportUrl] = useState(null); const [exportFilename, setExportFilename] = useState(null); - const [previewIndex, setPreviewIndex] = 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 loadFileAsNew = async (file: File) => { - setError(null); + 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([]); - setSelectedPages([]); - setLastSelectedVisualIndex(null); - setSubsetUrl(null); - setSubsetFilename(null); - setExportUrl(null); - setExportFilename(null); - setBaseThumbnails(null); - setReorderThumbnails(null); - setRotations({}); - setOrder([]); - setPreviewIndex(null); + 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); - setPdf(loaded); + const initialPages = createInitialPageRefs(loaded.pageCount); - const initialOrder = Array.from( - { length: loaded.pageCount }, - (_, i) => i - ); - setOrder(initialOrder); + setPdf(loaded); + setPages(initialPages); + latestPagesRef.current = initialPages; } catch (e) { console.error(e); setError('Failed to load PDF (see console).'); @@ -81,15 +139,13 @@ const App: React.FC = () => { }; const handleFileLoaded = (file: File) => { - // If no PDF loaded yet, just open it as before - if (!pdf || order.length === 0) { + if (!pdf || pages.length === 0) { void loadFileAsNew(file); } else { - // Otherwise, ask whether to merge or replace setPendingFile(file); setShowMergeOptions(true); setMergeMode('append'); - setMergeInsertAt(String(order.length + 1)); + setMergeInsertAt(String(pages.length + 1)); } }; @@ -101,7 +157,6 @@ const App: React.FC = () => { const handleMergeConfirm = async () => { if (!pendingFile) return; - // If there's no current PDF or the user chose overwrite, just load normally if (!pdf || mergeMode === 'overwrite') { await loadFileAsNew(pendingFile); setPendingFile(null); @@ -113,58 +168,49 @@ const App: React.FC = () => { setIsBusy(true); try { - // 1) Materialize the current in-memory state (order + rotations) - const currentBlob = await exportReordered(pdf, order, rotations); + // 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: order.length, + pageCount: pages.length, }; // 2) Load the new PDF const newPdf = await loadPdfFromFile(pendingFile); // 3) Determine insert position (0-based) - let insertAt = order.length; // default: append at end + 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), order.length); + insertAt = Math.min(Math.max(parsed - 1, 0), pages.length); } } else if (mergeMode === 'append') { - insertAt = order.length; + 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); - const mergedOrder = Array.from( - { length: mergedPdf.pageCount }, - (_, i) => i - ); - setOrder(mergedOrder); - setRotations({}); - setSelectedPages([]); + + setPages(mergedPages); + latestPagesRef.current = mergedPages; + setSelectedPageIds([]); setLastSelectedVisualIndex(null); setSplitResults([]); - - if (subsetUrl) { - URL.revokeObjectURL(subsetUrl); - setSubsetUrl(null); - setSubsetFilename(null); - } - if (exportUrl) { - URL.revokeObjectURL(exportUrl); - setExportUrl(null); - setExportFilename(null); - } - - setBaseThumbnails(null); - setReorderThumbnails(null); - setPreviewIndex(null); + resetGeneratedUrls(); + setReorderThumbnails({}); + thumbnailCacheRef.current.clear(); + previousPageRotationsRef.current.clear(); + setPreviewPageId(null); } catch (e) { console.error(e); setError('Failed to merge PDF (see console).'); @@ -175,146 +221,404 @@ const App: React.FC = () => { } }; + useEffect(() => { + latestPagesRef.current = pages; + }, [pages]); + useEffect(() => { if (!pdf) { - setBaseThumbnails(null); - setReorderThumbnails(null); + setReorderThumbnails({}); + thumbnailCacheRef.current.clear(); + previousPageRotationsRef.current.clear(); return; } - let cancelled = false; - (async () => { - try { - const thumbs = await generateThumbnails(pdf.arrayBuffer); - if (!cancelled) { - setBaseThumbnails(thumbs); - setReorderThumbnails(thumbs); + 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; + } } - } catch (e) { + + if (Object.keys(updates).length === 0) return; + + setReorderThumbnails((prev) => ({ + ...prev, + ...updates, + })); + }, + }).catch((e) => { + if (!controller.signal.aborted) { console.error(e); - if (!cancelled) { - setError('Failed to generate thumbnails (see console).'); - } + setError('Failed to generate thumbnails (see console).'); } - })(); + }); return () => { - cancelled = true; + controller.abort(); }; }, [pdf]); useEffect(() => { if (!pdf) { - setReorderThumbnails(null); + previousPageRotationsRef.current.clear(); return; } - let cancelled = false; - (async () => { - try { - const thumbs = await generateThumbnailsWithRotations( - pdf.arrayBuffer, - rotations - ); - if (!cancelled) { - setReorderThumbnails(thumbs); - } - } catch (e) { - console.error(e); - if (!cancelled) { - setError('Failed to generate rotated thumbnails (see console).'); - } + 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 () => { - cancelled = true; + controller.abort(); }; - }, [pdf, rotations]); + }, [pdf, pages]); + + useEffect(() => { + if (previewPageId != null && !pages.some((page) => page.id === previewPageId)) { + setPreviewPageId(null); + } + }, [previewPageId, pages]); const hasPdf = !!pdf; - const pageCount = pdf?.pageCount ?? 0; // === UI interactions === - const handleRotatePageClockwise = (pageIndex: number) => { - setRotations((prev) => { - const current = prev[pageIndex] ?? 0; - const next = (current + 90) % 360; - return { ...prev, [pageIndex]: next }; - }); + const handleRotatePageClockwise = (pageId: string) => { + setPages((prev) => + prev.map((page) => + page.id === pageId + ? { ...page, rotation: (normalizeRotation(page.rotation) + 90) % 360 } + : page + ) + ); }; - const handleRotatePageCounterclockwise = (pageIndex: number) => { - setRotations((prev) => { - const current = prev[pageIndex] ?? 0; - const next = (current + 270) % 360; - return { ...prev, [pageIndex]: next }; - }); + const handleRotatePageCounterclockwise = (pageId: string) => { + setPages((prev) => + prev.map((page) => + page.id === pageId + ? { ...page, rotation: (normalizeRotation(page.rotation) + 270) % 360 } + : page + ) + ); }; - const handleDeletePage = (pageIndex: number) => { - setOrder((prev) => prev.filter((p) => p !== pageIndex)); - setSelectedPages((prev) => prev.filter((p) => p !== pageIndex)); + const handleDeletePage = (pageId: string) => { + setPages((prev) => prev.filter((page) => page.id !== pageId)); + setSelectedPageIds((prev) => prev.filter((id) => id !== pageId)); }; - const handleReorder = (newOrder: number[]) => { - setOrder(newOrder); + const handleReorder = (newPages: PageRef[]) => { + setPages(newPages); }; const handleToggleSelect = ( - pageIndex: number, + pageId: string, visualIndex: number, e: React.MouseEvent ) => { - setSelectedPages((prev) => { - // Shift: add a range (visual positions) to the existing selection - if (e.shiftKey && lastSelectedVisualIndex !== null && order.length > 0) { + setSelectedPageIds((prev) => { + if (e.shiftKey && lastSelectedVisualIndex !== null && pages.length > 0) { const from = Math.min(lastSelectedVisualIndex, visualIndex); const to = Math.max(lastSelectedVisualIndex, visualIndex); - const rangeIndices = order.slice(from, to + 1); // original page indices + const rangeIds = pages.slice(from, to + 1).map((page) => page.id); const set = new Set(prev); - rangeIndices.forEach((idx) => set.add(idx)); + rangeIds.forEach((id) => set.add(id)); return Array.from(set); } - // Plain click: toggle this page - if (prev.includes(pageIndex)) { - return prev.filter((p) => p !== pageIndex); + if (prev.includes(pageId)) { + return prev.filter((id) => id !== pageId); } - return [...prev, pageIndex]; + return [...prev, pageId]; }); setLastSelectedVisualIndex(visualIndex); }; const handleSelectAll = () => { - setSelectedPages([...order]); + setSelectedPageIds(pages.map((page) => page.id)); setLastSelectedVisualIndex(null); }; const handleClearSelection = () => { - setSelectedPages([]); + setSelectedPageIds([]); setLastSelectedVisualIndex(null); }; const handleDeleteSelected = () => { - if (selectedPages.length === 0) return; - setOrder((prev) => prev.filter((p) => !selectedPages.includes(p))); - setSelectedPages([]); + if (selectedPageIds.length === 0) return; + const selectedSet = new Set(selectedPageIds); + setPages((prev) => prev.filter((page) => !selectedSet.has(page.id))); + setSelectedPageIds([]); setLastSelectedVisualIndex(null); }; - const handleOpenPreview = (pageIndex: number) => { - setPreviewIndex(pageIndex); + 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 = () => { - setPreviewIndex(null); + 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); @@ -331,7 +635,7 @@ const App: React.FC = () => { }; const handleExtractSelected = async () => { - if (!pdf || selectedPages.length === 0) return; + if (!pdf || selectedPageIds.length === 0) return setError(null); setIsBusy(true); @@ -342,10 +646,9 @@ const App: React.FC = () => { } try { - const selectedOrder = order.filter((idx) => - selectedPages.includes(idx) - ); - const blob = await exportReordered(pdf, selectedOrder, rotations); + 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`; @@ -360,7 +663,7 @@ const App: React.FC = () => { }; const handleExportReordered = async () => { - if (!pdf || order.length === 0) return; + if (!pdf || pages.length === 0) return; setError(null); setIsBusy(true); @@ -371,7 +674,7 @@ const App: React.FC = () => { } try { - const blob = await exportReordered(pdf, order, rotations); + const blob = await exportPages(pdf, pages); const url = URL.createObjectURL(blob); const base = pdf.name.replace(/\.pdf$/i, ''); const filename = `${base}_reordered.pdf`; @@ -385,18 +688,29 @@ const App: React.FC = () => { } }; + 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 && order.length > 0 && ( + {showMergeOptions && pendingFile && pdf && pages.length > 0 && (

Open file: merge or replace?

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

@@ -451,7 +765,7 @@ const App: React.FC = () => { setMergeInsertAt(e.target.value)} style={{ @@ -461,7 +775,7 @@ const App: React.FC = () => { }} />{' '} - (1 = before first page, {order.length + 1} = after last page) + (1 = before first page, {pages.length + 1} = after last page) @@ -489,16 +803,16 @@ const App: React.FC = () => { )} { { )} = 0 ? previewVisualIndex : null} + totalPages={pages.length} + canGoPrevious={canPreviewPrevious} + canGoNext={canPreviewNext} + onPrevious={handlePreviewPrevious} + onNext={handlePreviewNext} onClose={handleClosePreview} /> diff --git a/src/components/PageList.tsx b/src/components/PageList.tsx deleted file mode 100644 index c535998..0000000 --- a/src/components/PageList.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; - -interface PageListProps { - pageCount: number; - selectedPages: number[]; - onTogglePage: (index: number) => void; - thumbnails: string[] | null; -} - -const PageList: React.FC = ({ - pageCount, - selectedPages, - onTogglePage, - thumbnails, -}) => { - if (pageCount === 0) return null; - - const pages = Array.from({ length: pageCount }, (_, i) => i); - - return ( -
-

2. Pages

-

- Thumbnails are generated in your browser. Click to select pages (used by - future tools). -

-
- {pages.map((i) => { - const selected = selectedPages.includes(i); - const thumb = thumbnails?.[i]; - - return ( - - ); - })} -
-
- ); -}; - -export default PageList; diff --git a/src/components/PagePreviewModal.tsx b/src/components/PagePreviewModal.tsx index 296e80a..11417ee 100644 --- a/src/components/PagePreviewModal.tsx +++ b/src/components/PagePreviewModal.tsx @@ -10,8 +10,17 @@ import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url'; interface PagePreviewModalProps { isOpen: boolean; pdf: PdfFile | null; - pageIndex: number | null; // original page index (0-based) + pageIndex: number | null; // original page index, 0-based rotation: number; // degrees + + visualIndex: number | null; // current position in order, 0-based + totalPages: number; + + canGoPrevious: boolean; + canGoNext: boolean; + onPrevious: () => void; + onNext: () => void; + onClose: () => void; } @@ -20,10 +29,45 @@ const PagePreviewModal: React.FC = ({ pdf, pageIndex, rotation, + visualIndex, + totalPages, + canGoPrevious, + canGoNext, + onPrevious, + onNext, onClose, }) => { const canvasRef = useRef(null); + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + + if (e.key === 'ArrowLeft' && canGoPrevious) { + e.preventDefault(); + onPrevious(); + return; + } + + if (e.key === 'ArrowRight' && canGoNext) { + e.preventDefault(); + onNext(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, canGoPrevious, canGoNext, onPrevious, onNext, onClose]); + useEffect(() => { if (!isOpen || !pdf || pageIndex == null) return; @@ -31,6 +75,14 @@ const PagePreviewModal: React.FC = ({ (async () => { try { + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + } + // copy data for pdf.js (avoid detaching original ArrayBuffer) const src = new Uint8Array(pdf.arrayBuffer); const copy = new Uint8Array(src.byteLength); @@ -44,16 +96,23 @@ const PagePreviewModal: React.FC = ({ if (cancelled) return; const viewport = page.getViewport({ scale: 1 }); + const maxWidth = Math.min(window.innerWidth * 0.9, 800); - const scale = maxWidth / viewport.width; + const maxHeight = window.innerHeight * 0.75; + + const scale = Math.min( + maxWidth / viewport.width, + maxHeight / viewport.height + ); + const scaledViewport = page.getViewport({ scale }); - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; + const visibleCanvas = canvasRef.current; + if (!visibleCanvas) return; + + const visibleCtx = visibleCanvas.getContext('2d'); + if (!visibleCtx) return; - // base size let canvasWidth = scaledViewport.width; let canvasHeight = scaledViewport.height; @@ -64,10 +123,9 @@ const PagePreviewModal: React.FC = ({ canvasHeight = scaledViewport.width; } - canvas.width = canvasWidth; - canvas.height = canvasHeight; + visibleCanvas.width = canvasWidth; + visibleCanvas.height = canvasHeight; - // render into an offscreen canvas first const baseCanvas = document.createElement('canvas'); const baseCtx = baseCanvas.getContext('2d'); if (!baseCtx) return; @@ -79,31 +137,29 @@ const PagePreviewModal: React.FC = ({ canvasContext: baseCtx, viewport: scaledViewport, }); + await renderTask.promise; if (cancelled) return; - // draw rotated onto visible canvas - ctx.save(); + visibleCtx.save(); switch (angle) { case 90: - ctx.translate(canvasWidth, 0); - ctx.rotate((angle * Math.PI) / 180); + visibleCtx.translate(canvasWidth, 0); + visibleCtx.rotate((angle * Math.PI) / 180); break; case 180: - ctx.translate(canvasWidth, canvasHeight); - ctx.rotate((angle * Math.PI) / 180); + visibleCtx.translate(canvasWidth, canvasHeight); + visibleCtx.rotate((angle * Math.PI) / 180); break; case 270: - ctx.translate(0, canvasHeight); - ctx.rotate((angle * Math.PI) / 180); - break; - default: + visibleCtx.translate(0, canvasHeight); + visibleCtx.rotate((angle * Math.PI) / 180); break; } - ctx.drawImage(baseCanvas, 0, 0); - ctx.restore(); + visibleCtx.drawImage(baseCanvas, 0, 0); + visibleCtx.restore(); } catch (e) { console.error('Error rendering preview', e); } @@ -116,6 +172,11 @@ const PagePreviewModal: React.FC = ({ if (!isOpen || !pdf || pageIndex == null) return null; + const positionLabel = + visualIndex != null && visualIndex >= 0 + ? `${visualIndex + 1} / ${totalPages}` + : `Page ${pageIndex + 1}`; + return (
= ({
e.stopPropagation()} style={{ + position: 'relative', background: '#111827', borderRadius: '0.75rem', padding: '0.75rem', @@ -142,25 +204,107 @@ const PagePreviewModal: React.FC = ({ flexDirection: 'column', alignItems: 'center', gap: '0.5rem', + overflow: 'visible', }} > -
- -
+ {/* Previous page */} + + + {/* Next page */} + + + {/* Close */} + + = ({ borderRadius: '0.5rem', }} /> +
- Page {pageIndex + 1} · Rot {rotation}° + {positionLabel} · Original page {pageIndex + 1} · Rot {rotation}°
); }; -export default PagePreviewModal; +export default PagePreviewModal; \ No newline at end of file diff --git a/src/components/ReorderPanel.tsx b/src/components/ReorderPanel.tsx index 0f64bb1..d26e839 100644 --- a/src/components/ReorderPanel.tsx +++ b/src/components/ReorderPanel.tsx @@ -1,41 +1,42 @@ -import React, { useState, useRef } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; +import type { PageRef } from '../pdf/pdfTypes'; interface ReorderPanelProps { - order: number[]; // current page order (page indices) - thumbnails: string[] | null; // thumbnails by original page index + pages: PageRef[]; + thumbnails: Record; isBusy: boolean; hasPdf: boolean; - rotations: Record; - selectedPages: number[]; // selected original page indices + selectedPageIds: string[]; - onRotateClockwise: (pageIndex: number) => void; - onRotateCounterclockwise: (pageIndex: number) => void; - onDelete: (pageIndex: number) => void; - onReorder: (newOrder: number[]) => void; + onRotateClockwise: (pageId: string) => void; + onRotateCounterclockwise: (pageId: string) => void; + onDelete: (pageId: string) => void; + onReorder: (newPages: PageRef[]) => void; + onCopyPagesToSlot: (pageIds: string[], insertSlot: number) => void; onToggleSelect: ( - pageIndex: number, + pageId: string, visualIndex: number, e: React.MouseEvent ) => void; onSelectAll: () => void; - onOpenPreview: (pageIndex: number) => void; + onOpenPreview: (pageId: string) => void; onClearSelection: () => void; onDeleteSelected: () => void; } const ReorderPanel: React.FC = ({ - order, + pages, thumbnails, isBusy, hasPdf, - rotations, - selectedPages, + selectedPageIds, onRotateClockwise, onRotateCounterclockwise, onDelete, onReorder, + onCopyPagesToSlot, onToggleSelect, onSelectAll, onOpenPreview, @@ -43,10 +44,16 @@ const ReorderPanel: React.FC = ({ onDeleteSelected, }) => { const [draggingIndex, setDraggingIndex] = useState(null); - const [dropIndex, setDropIndex] = useState(null); // slot 0..order.length + const [dropIndex, setDropIndex] = useState(null); + + const [isCopyDragging, setIsCopyDragging] = useState(false); + const [copyDialogOpen, setCopyDialogOpen] = useState(false); + const [copyTargetPosition, setCopyTargetPosition] = useState(''); + const [copyDialogError, setCopyDialogError] = useState(null); + const dragGhostRef = useRef(null); - const isSelected = (pageIndex: number) => selectedPages.includes(pageIndex); + const isSelected = (pageId: string) => selectedPageIds.includes(pageId); const cleanupDragGhost = () => { if (dragGhostRef.current && dragGhostRef.current.parentNode) { @@ -55,11 +62,31 @@ const ReorderPanel: React.FC = ({ dragGhostRef.current = null; }; + const isCopyModifierPressed = (e: React.DragEvent) => { + return e.ctrlKey || e.metaKey; + }; + + const getDraggedPages = (visualIndex: number): PageRef[] => { + const draggedPage = pages[visualIndex]; + if (!draggedPage) return []; + + const selectedInVisualOrder = pages.filter((page) => + selectedPageIds.includes(page.id) + ); + + const draggingIsSelected = + selectedInVisualOrder.length > 0 && + selectedInVisualOrder.some((page) => page.id === draggedPage.id); + + return draggingIsSelected ? selectedInVisualOrder : [draggedPage]; + }; + const createDragGhost = (e: React.DragEvent, count: number) => { cleanupDragGhost(); const ghost = document.createElement('div'); ghost.textContent = count === 1 ? '1 page' : `${count} pages`; + ghost.style.position = 'fixed'; ghost.style.top = '0'; ghost.style.left = '0'; @@ -68,49 +95,48 @@ const ReorderPanel: React.FC = ({ ghost.style.background = '#111827'; ghost.style.color = '#e5e7eb'; ghost.style.fontSize = '12px'; - ghost.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ghost.style.boxShadow = '0 4px 8px rgba(15, 23, 42, 0.4)'; + ghost.style.fontFamily = + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; ghost.style.zIndex = '9999'; document.body.appendChild(ghost); dragGhostRef.current = ghost; - // center drag image under cursor const rect = ghost.getBoundingClientRect(); e.dataTransfer.setDragImage(ghost, rect.width / 2, rect.height / 2); }; const handleDragStart = (visualIndex: number) => (e: React.DragEvent) => { setDraggingIndex(visualIndex); - setDropIndex(visualIndex); // initial: before itself - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', String(visualIndex)); // Firefox + setDropIndex(visualIndex); - const draggedPageIndex = order[visualIndex]; - const selectedInVisualOrder = order.filter((p) => - selectedPages.includes(p) - ); - const draggingIsSelected = - selectedInVisualOrder.length > 0 && - selectedInVisualOrder.includes(draggedPageIndex); + const copying = isCopyModifierPressed(e); + setIsCopyDragging(copying); - const movingPages = draggingIsSelected - ? selectedInVisualOrder - : [draggedPageIndex]; + e.dataTransfer.effectAllowed = 'copyMove'; + e.dataTransfer.dropEffect = copying ? 'copy' : 'move'; + e.dataTransfer.setData('text/plain', String(visualIndex)); - createDragGhost(e, movingPages.length); + const draggedPages = getDraggedPages(visualIndex); + createDragGhost(e, draggedPages.length); }; const handleDragEnd = () => { cleanupDragGhost(); setDraggingIndex(null); setDropIndex(null); + setIsCopyDragging(false); }; const handleCardDragOver = (visualIndex: number) => (e: React.DragEvent) => { if (draggingIndex == null) return; + e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; + + const copying = isCopyModifierPressed(e); + setIsCopyDragging(copying); + + e.dataTransfer.dropEffect = copying ? 'copy' : 'move'; const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect(); const x = e.clientX - rect.left; @@ -121,9 +147,15 @@ const ReorderPanel: React.FC = ({ const handleEndSlotDragOver = (e: React.DragEvent) => { if (draggingIndex == null) return; + e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setDropIndex(order.length); + + const copying = isCopyModifierPressed(e); + setIsCopyDragging(copying); + + e.dataTransfer.dropEffect = copying ? 'copy' : 'move'; + + setDropIndex(pages.length); }; const handleDrop = (e: React.DragEvent) => { @@ -132,78 +164,126 @@ const ReorderPanel: React.FC = ({ if (draggingIndex == null || dropIndex == null) return; - const draggedPageIndex = order[draggingIndex]; + const draggedPages = getDraggedPages(draggingIndex); + if (draggedPages.length === 0) return; - // Selected pages in current visual order - const selectedInVisualOrder = order.filter((p) => - selectedPages.includes(p) - ); + const shouldCopy = isCopyModifierPressed(e) || isCopyDragging; - const draggingIsSelected = - selectedInVisualOrder.length > 0 && - selectedInVisualOrder.includes(draggedPageIndex); + if (shouldCopy) { + onCopyPagesToSlot( + draggedPages.map((page) => page.id), + dropIndex + ); - // Pages that will move: - // - if dragging selected -> move full selection (in visual order) - // - else -> only the dragged page - const movingPages = draggingIsSelected - ? selectedInVisualOrder - : [draggedPageIndex]; + setDraggingIndex(null); + setDropIndex(null); + setIsCopyDragging(false); + return; + } - // Map from page index to visual position - const indexMap = new Map(); - order.forEach((p, idx) => indexMap.set(p, idx)); + const indexMap = new Map(); + pages.forEach((page, idx) => indexMap.set(page.id, idx)); - // how many of the moving pages were before the drop slot? - const countBefore = movingPages.reduce((count, p) => { - const idx = indexMap.get(p); + const countBefore = draggedPages.reduce((count, page) => { + const idx = indexMap.get(page.id); if (idx != null && idx < dropIndex) return count + 1; return count; }, 0); const adjustedSlot = dropIndex - countBefore; + const movingSet = new Set(draggedPages.map((page) => page.id)); + const remaining = pages.filter((page) => !movingSet.has(page.id)); - // Remove moving pages from current order - const movingSet = new Set(movingPages); - const remaining = order.filter((p) => !movingSet.has(p)); - - // Insert in same relative order at new slot - const newOrder = [ + const newPages = [ ...remaining.slice(0, adjustedSlot), - ...movingPages, + ...draggedPages, ...remaining.slice(adjustedSlot), ]; - onReorder(newOrder); + onReorder(newPages); + + setDraggingIndex(null); + setDropIndex(null); + setIsCopyDragging(false); + }; + + const handleDeleteClick = (pageId: string) => () => { + onDelete(pageId); setDraggingIndex(null); setDropIndex(null); }; - const handleDeleteClick = (pageIndex: number) => () => { - onDelete(pageIndex); - setDraggingIndex(null); - setDropIndex(null); + const handleRotateClickClockwise = (pageId: string) => () => { + onRotateClockwise(pageId); }; - const handleRotateClickClockwise = (pageIndex: number) => () => { - onRotateClockwise(pageIndex); + const handleRotateClickCounterclockwise = (pageId: string) => () => { + onRotateCounterclockwise(pageId); }; - const handleRotateClickCounterclockwise = (pageIndex: number) => () => { - onRotateCounterclockwise(pageIndex); - }; - - const handleCardClick = (pageIndex: number) => () => { - onOpenPreview(pageIndex); + const handleCardClick = (pageId: string) => () => { + onOpenPreview(pageId); }; const handleCheckboxClick = - (pageIndex: number, visualIndex: number) => - (e: React.MouseEvent) => { - e.stopPropagation(); // don't trigger preview - onToggleSelect(pageIndex, visualIndex, e); + (pageId: string, visualIndex: number) => + (e: React.MouseEvent) => { + e.stopPropagation(); // don't trigger preview + onToggleSelect(pageId, visualIndex, e); + }; + + const handleCopySelectedClick = () => { + if (selectedPageIds.length === 0) return; + + setCopyTargetPosition(String(pages.length + 1)); // default: after last page + setCopyDialogError(null); + setCopyDialogOpen(true); + }; + + const handleCopyDialogCancel = () => { + setCopyDialogOpen(false); + setCopyDialogError(null); + }; + + const handleCopyDialogConfirm = (e?: React.FormEvent) => { + e?.preventDefault(); + + if (selectedPageIds.length === 0) { + setCopyDialogError('No pages selected.'); + return; + } + + const maxPosition = pages.length + 1; + const parsed = Number.parseInt(copyTargetPosition.trim(), 10); + + if (!Number.isFinite(parsed) || parsed < 1 || parsed > maxPosition) { + setCopyDialogError(`Please enter a number between 1 and ${maxPosition}.`); + return; + } + + onCopyPagesToSlot(selectedPageIds, parsed - 1); + + setCopyDialogOpen(false); + setCopyDialogError(null); + }; + + useEffect(() => { + if (!copyDialogOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleCopyDialogCancel(); + } }; + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [copyDialogOpen]); + if (!hasPdf) { return (
@@ -222,321 +302,547 @@ const ReorderPanel: React.FC = ({ draggingIndex !== null; const showEndLine = () => - dropIndex !== null && dropIndex === order.length && draggingIndex !== null; + dropIndex !== null && dropIndex === pages.length && draggingIndex !== null; // For highlighting the whole selection while dragging it - const draggingPageIndex = - draggingIndex != null ? order[draggingIndex] : null; + const draggingPage = draggingIndex != null ? pages[draggingIndex] : null; const draggingSelectionActive = - draggingPageIndex != null && - selectedPages.length > 0 && - selectedPages.includes(draggingPageIndex); + draggingPage != null && + selectedPageIds.length > 0 && + selectedPageIds.includes(draggingPage.id); + const dropIndicatorColor = isCopyDragging ? '#16a34a' : '#2563eb'; return ( -
-

Pages

-

- Tap/click a page to preview it. Use the checkbox to select pages - (Shift for ranges). Drag to reorder; dragging a selected page moves the - whole selection. -

+ <> +
+

Pages

+

+ Tap/click a page to preview it. Use the checkbox to select pages + (Shift for ranges). Drag to reorder; dragging a selected page moves the + whole selection. Hold Ctrl/⌘ while dropping to copy instead of move. + Shortcuts: Ctrl/⌘+A selects all, Delete removes selected pages, Esc clears + selection. +

-
- - Selected: {selectedPages.length} -
- {selectedPages.length > 0 && ( + + Selected: {selectedPageIds.length} + +
+ {selectedPageIds.length > 0 && ( + + )} + {selectedPageIds.length > 0 && ( + + )} + +
+
+ +
+ {pages.map((page, visualIndex) => { + const thumb = thumbnails[page.id]; + const rotation = page.rotation; + const selected = isSelected(page.id); + + const isDraggingCard = + draggingIndex != null && + ((draggingSelectionActive && selected) || + (!draggingSelectionActive && visualIndex === draggingIndex)); + + return ( +
+ {/* selection checkbox */} + + + {/* left drop indicator */} + {showLeftLine(visualIndex) && ( +
+ )} + + {/* right drop indicator */} + {showRightLine(visualIndex) && ( +
+ )} + +
+ {thumb ? ( + {`Page + ) : ( +
+ )} +
+ + Page {page.sourcePageIndex + 1} + + Pos {visualIndex + 1} · Rot {rotation}° + + +
+ + + +
+
+ ); + })} + + {/* end slot for dropping after the last card */} + {pages.length > 0 && ( +
+ {showEndLine() && ( +
+ )} +
)} - -
-
- {order.map((pageIndex, visualIndex) => { - const thumb = thumbnails?.[pageIndex]; - const rotation = rotations[pageIndex] ?? 0; - const selected = isSelected(pageIndex); - - const isDraggingCard = - draggingIndex != null && - ((draggingSelectionActive && selected) || - (!draggingSelectionActive && visualIndex === draggingIndex)); - - return ( + {copyDialogOpen && ( +
{ + if (e.target === e.currentTarget) { + handleCopyDialogCancel(); + } + }} + style={{ + position: 'fixed', + inset: 0, + zIndex: 60, + background: 'rgba(15, 23, 42, 0.55)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '1rem', + }} + > +
- {/* selection checkbox */} +

+ Copy selected pages +

+ - - {/* left drop indicator */} - {showLeftLine(visualIndex) && ( -
- )} - - {/* right drop indicator */} - {showRightLine(visualIndex) && ( -
- )} - - {thumb ? ( - {`Page - ) : ( -
- )} - Page {pageIndex + 1} - - Pos {visualIndex + 1} · Rot {rotation}° - - -
- - - -
- ); - })} - {/* end slot for dropping after the last card */} - {order.length > 0 && ( -
- {showEndLine() && ( -
+ Copy{' '} + + {selectedPageIds.length === 1 + ? '1 selected page' + : `${selectedPageIds.length} selected pages`} + {' '} + to a new position. +

+ + + +
+
1 = before the first page
+
{pages.length + 1} = after the last page
+
+ + {copyDialogError && ( +
+ {copyDialogError} +
)} -
- )} -
-
+ +
+ + + +
+ +
+ )} + ); }; diff --git a/src/pdf/pdfService.ts b/src/pdf/pdfService.ts index f174179..3891406 100644 --- a/src/pdf/pdfService.ts +++ b/src/pdf/pdfService.ts @@ -1,5 +1,5 @@ import { PDFDocument, degrees } from 'pdf-lib'; -import type { PdfFile, SplitResult, Range } from './pdfTypes'; +import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes'; function createId() { return Math.random().toString(36).slice(2); @@ -53,19 +53,18 @@ export async function mergePdfFiles( } const bytes = await mergedDoc.save(); - const buffer = bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength - ); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); const baseName = basePdf.name.replace(/\.pdf$/i, ''); const newName = newPdf.name.replace(/\.pdf$/i, ''); return { + id: createId(), name: `${baseName}_plus_${newName}.pdf`, arrayBuffer: buffer, pageCount: mergedDoc.getPageCount(), - doc: mergedDoc, // 👈 important + doc: mergedDoc, }; } @@ -93,7 +92,7 @@ export async function splitIntoSinglePages( if (title) newDoc.setTitle(title); if (author) newDoc.setAuthor(author); if (subject) newDoc.setSubject(subject); - if (keywords) newDoc.setKeywords(keywords); + if (keywords) newDoc.setKeywords([keywords]); if (producer) newDoc.setProducer(producer); if (creator) newDoc.setCreator(creator); if (creationDate) newDoc.setCreationDate(creationDate); @@ -154,30 +153,32 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise { return new Blob([bytes], { type: 'application/pdf' }); } -export async function exportReordered( +export async function exportPages( pdf: PdfFile, - order: number[], - rotations?: Record + pages: PageRef[] ): Promise { const { doc } = pdf; const pageCount = doc.getPageCount(); - if (order.length === 0) { - throw new Error('Order must contain at least one page'); + if (pages.length === 0) { + throw new Error('Pages must contain at least one page'); } - if (order.some((i) => i < 0 || i >= pageCount)) { - throw new Error('Order contains invalid page indices'); + if ( + pages.some( + (page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount + ) + ) { + throw new Error('Pages contain invalid source page indices'); } const newDoc = await PDFDocument.create(); - const indices = [...order]; + const indices = pages.map((page) => page.sourcePageIndex); const copiedPages = await newDoc.copyPages(doc, indices); copiedPages.forEach((page, idx) => { - const originalIndex = indices[idx]; - const angle = rotations?.[originalIndex]; + const angle = pages[idx].rotation; if (typeof angle === 'number' && angle % 360 !== 0) { page.setRotation(degrees(angle)); @@ -189,3 +190,18 @@ export async function exportReordered( const bytes = await newDoc.save(); return new Blob([bytes], { type: 'application/pdf' }); } + +export async function exportReordered( + pdf: PdfFile, + order: number[], + rotations?: Record +): Promise { + return exportPages( + pdf, + order.map((sourcePageIndex) => ({ + id: String(sourcePageIndex), + sourcePageIndex, + rotation: rotations?.[sourcePageIndex] ?? 0, + })) + ); +} \ No newline at end of file diff --git a/src/pdf/pdfThumbnailService.ts b/src/pdf/pdfThumbnailService.ts index 9955b27..4bfdff1 100644 --- a/src/pdf/pdfThumbnailService.ts +++ b/src/pdf/pdfThumbnailService.ts @@ -19,115 +19,190 @@ function makePdfJsDataCopy(arrayBuffer: ArrayBuffer): Uint8Array { return copy; } +interface ThumbnailUpdate { + pageIndex: number; + dataUrl: string; +} + +interface ThumbnailGenerationOptions { + maxHeight?: number; + maxWidth?: number; + concurrency?: number; + /** + * Optional subset of 0-based page indices to render. + * If omitted, all pages are rendered. + */ + pageIndices?: number[]; + signal?: AbortSignal; + onThumbnail?: (update: ThumbnailUpdate) => void; +} + + /** * Unrotated thumbnails – used e.g. in the Split/Extract view. */ -export async function generateThumbnails( +export async function generateThumbnailsProgressive( arrayBuffer: ArrayBuffer, - maxHeight = 150 + options: ThumbnailGenerationOptions = {} ): Promise { - return generateThumbnailsInternal(arrayBuffer, {}, maxHeight); + return generateThumbnailsInternal(arrayBuffer, {}, options); } /** * Thumbnails that respect per-page rotations (for the Reorder view). */ -export async function generateThumbnailsWithRotations( +export async function generateThumbnailsWithRotationsProgressive( arrayBuffer: ArrayBuffer, rotations: RotationsMap, - maxHeight = 150 + options: ThumbnailGenerationOptions = {} ): Promise { - return generateThumbnailsInternal(arrayBuffer, rotations, maxHeight); + return generateThumbnailsInternal(arrayBuffer, rotations, options); } async function generateThumbnailsInternal( arrayBuffer: ArrayBuffer, rotations: RotationsMap, - maxHeight: number + options: ThumbnailGenerationOptions = {} ): Promise { - // IMPORTANT: use a COPY so pdf.js can detach it without breaking future calls - const dataCopy = makePdfJsDataCopy(arrayBuffer); + const maxHeight = options.maxHeight ?? 150; + const maxWidth = options.maxWidth ?? 140; + const concurrency = Math.max(1, Math.min(options.concurrency ?? 3, 6)); + const signal = options.signal; + const dataCopy = makePdfJsDataCopy(arrayBuffer); const loadingTask = pdfjsLib.getDocument({ data: dataCopy }); const pdf = await loadingTask.promise; - const thumbs: string[] = []; + const thumbs = Array(pdf.numPages).fill(''); + + const pageNums = options.pageIndices + ? Array.from( + new Set( + options.pageIndices + .filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages) + .map((pageIndex) => pageIndex + 1) + ) + ) + : Array.from({ length: pdf.numPages }, (_, index) => index + 1); + + let nextPageIndex = 0; + + const renderOne = async (pageNum: number) => { + if (signal?.aborted) return; - for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); - const viewport = page.getViewport({ scale: 1 }); - const scale = maxHeight / viewport.height; - const scaledViewport = page.getViewport({ scale }); + if (signal?.aborted) return; - // First render unrotated page into a canvas - const baseCanvas = document.createElement('canvas'); - const baseCtx = baseCanvas.getContext('2d'); - if (!baseCtx) { - thumbs.push(''); - continue; + const pageIndex = pageNum - 1; + const dataUrl = await renderPageThumbnail( + page, + pageIndex, + rotations, + maxHeight, + maxWidth + ); + + if (signal?.aborted) return; + + thumbs[pageIndex] = dataUrl; + options.onThumbnail?.({ pageIndex, dataUrl }); + }; + + const worker = async () => { + while (!signal?.aborted) { + const pageNum = pageNums[nextPageIndex]; + nextPageIndex += 1; + + if (pageNum == null) return; + + await renderOne(pageNum); + + // Let React/browser paint between batches. + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); } + }; - baseCanvas.width = scaledViewport.width; - baseCanvas.height = scaledViewport.height; + const workerCount = Math.min(concurrency, pageNums.length); + if (workerCount === 0) return thumbs; - const renderTask = page.render({ - canvasContext: baseCtx, - viewport: scaledViewport, - }); - await renderTask.promise; - - const originalIndex = pageNum - 1; - const rotationDegRaw = rotations[originalIndex] ?? 0; - const rotationDeg = ((rotationDegRaw % 360) + 360) % 360; // normalize 0–359 - - if (rotationDeg === 0) { - thumbs.push(baseCanvas.toDataURL('image/png')); - continue; - } - - // Re-render onto a second canvas with rotation applied - const rotatedCanvas = document.createElement('canvas'); - const rotatedCtx = rotatedCanvas.getContext('2d'); - if (!rotatedCtx) { - thumbs.push(baseCanvas.toDataURL('image/png')); - continue; - } - - const rad = (rotationDeg * Math.PI) / 180; - - if (rotationDeg === 90 || rotationDeg === 270) { - rotatedCanvas.width = baseCanvas.height; - rotatedCanvas.height = baseCanvas.width; - } else { - rotatedCanvas.width = baseCanvas.width; - rotatedCanvas.height = baseCanvas.height; - } - - rotatedCtx.save(); - - switch (rotationDeg) { - case 90: - rotatedCtx.translate(rotatedCanvas.width, 0); - rotatedCtx.rotate(rad); - break; - case 180: - rotatedCtx.translate(rotatedCanvas.width, rotatedCanvas.height); - rotatedCtx.rotate(rad); - break; - case 270: - rotatedCtx.translate(0, rotatedCanvas.height); - rotatedCtx.rotate(rad); - break; - default: - // fallback: no rotation - break; - } - - rotatedCtx.drawImage(baseCanvas, 0, 0); - rotatedCtx.restore(); - - thumbs.push(rotatedCanvas.toDataURL('image/png')); - } + await Promise.all(Array.from({ length: workerCount }, worker)); return thumbs; } + +async function renderPageThumbnail( + page: Awaited['promise']>['getPage']>>, + originalIndex: number, + rotations: RotationsMap, + maxHeight: number, + maxWidth: number +): Promise { + const viewport = page.getViewport({ scale: 1 }); + const scaleH = maxHeight / viewport.height; + const scaleW = maxWidth / viewport.width; + const scale = Math.min(scaleH, scaleW); + const scaledViewport = page.getViewport({ scale }); + + const baseCanvas = document.createElement('canvas'); + const baseCtx = baseCanvas.getContext('2d'); + + if (!baseCtx) return ''; + + baseCanvas.width = scaledViewport.width; + baseCanvas.height = scaledViewport.height; + + const renderTask = page.render({ + canvasContext: baseCtx, + viewport: scaledViewport, + }); + + await renderTask.promise; + + const rotationDegRaw = rotations[originalIndex] ?? 0; + const rotationDeg = ((rotationDegRaw % 360) + 360) % 360; + + if (rotationDeg === 0) { + return baseCanvas.toDataURL('image/png'); + } + + const rotatedCanvas = document.createElement('canvas'); + const rotatedCtx = rotatedCanvas.getContext('2d'); + + if (!rotatedCtx) { + return baseCanvas.toDataURL('image/png'); + } + + const rad = (rotationDeg * Math.PI) / 180; + + if (rotationDeg === 90 || rotationDeg === 270) { + rotatedCanvas.width = baseCanvas.height; + rotatedCanvas.height = baseCanvas.width; + } else { + rotatedCanvas.width = baseCanvas.width; + rotatedCanvas.height = baseCanvas.height; + } + + rotatedCtx.save(); + + switch (rotationDeg) { + case 90: + rotatedCtx.translate(rotatedCanvas.width, 0); + rotatedCtx.rotate(rad); + break; + case 180: + rotatedCtx.translate(rotatedCanvas.width, rotatedCanvas.height); + rotatedCtx.rotate(rad); + break; + case 270: + rotatedCtx.translate(0, rotatedCanvas.height); + rotatedCtx.rotate(rad); + break; + } + + rotatedCtx.drawImage(baseCanvas, 0, 0); + rotatedCtx.restore(); + + return rotatedCanvas.toDataURL('image/png'); +} \ No newline at end of file diff --git a/src/pdf/pdfTypes.ts b/src/pdf/pdfTypes.ts index f22eaf9..e84e997 100644 --- a/src/pdf/pdfTypes.ts +++ b/src/pdf/pdfTypes.ts @@ -8,6 +8,12 @@ export interface PdfFile { arrayBuffer: ArrayBuffer; } +export interface PageRef { + id: string; + sourcePageIndex: number; + rotation: number; +} + export interface SplitResult { pageIndex: number; blob: Blob; diff --git a/src/styles.css b/src/styles.css index bfb2c3f..b99f0d2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -120,12 +120,6 @@ button.secondary { margin-top: 0.5rem; } -.page-list { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - .app-root { min-height: 100vh; background-color: #f3f4f6; @@ -175,20 +169,6 @@ button.secondary { width: 100%; } -/* Slightly less rounded page pills so they look like rectangles */ -.page-pill { - padding: 0.2rem 0.5rem; - border-radius: 0.5rem; /* was 999px */ - border: 1px solid #e5e7eb; - font-size: 0.8rem; - background: #f9fafb; -} - -.page-pill.selected { - background: #dbeafe; - border-color: #93c5fd; -} - .download-link { display: inline-block; margin: 0.15rem 0;