diff --git a/README.md b/README.md index 21abf88..34b9743 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ URL opening, webpage conversion, and Word conversion require the desktop app. Fa when you reopen a PDF, except for password-protected documents. - **Organize pages** from either sidebar tab. Drag thumbnails or enter a new position, or turn or delete selected pages. Ctrl-click (⌘-click on macOS) or Shift-click to select several, then choose **Rotate left**, **Rotate right**, or **Delete**. Undo and redo with `Ctrl+Z` and - `Ctrl+Shift+Z`. Right-click selected thumbnails in **Pages** to apply these actions directly. Save, then open the + `Ctrl+Shift+Z`. Right-click selected thumbnails in **Pages** to apply these actions directly. Use **Insert PDF…** to add another PDF before or after the selected page, at the start, + or at the end. You can also drop a PDF on the organizer. Insertion requires both PDFs to be unencrypted. Save, then open the organized PDF copy; the original file stays unchanged. - **Export selected pages** from the Pages sidebar. Ctrl-click thumbnails (⌘-click on macOS) to select individual pages, or Shift-click for a range. Right-click a selected thumbnail to save those pages in document order as a new PDF. - **Search as you type:** match case or whole words, see the current match and total count, browse per-page snippets diff --git a/scripts/fixtures/protected-organizer.pdf b/scripts/fixtures/protected-organizer.pdf new file mode 100644 index 0000000..32787b0 Binary files /dev/null and b/scripts/fixtures/protected-organizer.pdf differ diff --git a/scripts/smoke-reader-tools.mjs b/scripts/smoke-reader-tools.mjs index 8ecebaa..dc15538 100644 --- a/scripts/smoke-reader-tools.mjs +++ b/scripts/smoke-reader-tools.mjs @@ -920,7 +920,88 @@ async function checkOrganizePages(app, page, folder, shots) { assert.deepEqual(copy.map(p => p.rotation), [90, 90, 0]); await organizer.getByRole("button", { name: "Done", exact: true }).click(); await expectShown([1, 2, 3, 4]); - console.log("ok organizing pages deletes, undoes and redoes, keeps at least one page, and saves the chosen pages in order"); + console.log("ok organizing pages deletes and rotates selected pages, supports undo, and saves the draft order"); + + // Select files through the real input without showing a platform file picker during this hidden run. + const picker = page.getByLabel("PDF to insert", { exact: true }); + await picker.evaluate(input => { input.click = () => {}; }); + await thumb(2).click({ button: "right" }); + await page.getByRole("menuitem", { name: "Insert PDF after selection…" }).click(); + const incoming = { name: "Appendix.pdf", mimeType: "application/pdf", buffer: Buffer.from(labeledPdf(["Appendix A", "Appendix B"])) }; + await sidebar.screenshot({ path: path.join(shots, "organizer-before-insert.png") }); + await picker.setInputFiles(incoming); + const imported = sidebar.getByRole("button", { name: /from Appendix.pdf, position/ }); + await imported.nth(1).waitFor(); + assert.deepEqual(await imported.evaluateAll(nodes => nodes.map(n => n.getAttribute("aria-label"))), + ["Page 1 from Appendix.pdf, position 3", "Page 2 from Appendix.pdf, position 4"]); + await sidebar.screenshot({ path: path.join(shots, "organizer-after-insert.png") }); + await organizer.getByRole("button", { name: "Undo", exact: true }).click(); + assert.equal(await imported.count(), 0); + await organizer.getByRole("button", { name: "Redo", exact: true }).click(); + await imported.first().click(); + await organizer.getByRole("button", { name: "Rotate right", exact: true }).click(); + await organizer.getByLabel("Move page 1 to").fill("1"); + await organizer.getByRole("button", { name: "Move", exact: true }).click(); + const insertedFile = path.join(folder, "with-appendix.pdf"); + await captureDownload(app, insertedFile); + await organizer.getByRole("button", { name: "Save organized copy" }).click(); + await organizer.getByRole("button", { name: "Open organized copy" }).waitFor(); + assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed"); + const inserted = await readCopy(page, insertedFile); + assert.deepEqual(inserted.map(p => p.text), ["Appendix A", "First page", "Second page", "Appendix B", "Third page", "Fourth page"]); + assert.deepEqual(inserted.map(p => p.rotation), [90, 0, 0, 0, 0, 0]); + for (const [placement, expected] of [["start", 1], ["before", 3], ["end", 7]]) { + await sidebar.locator('.thumb[data-key="2"]').click(); + await organizer.getByLabel("Insert pages at").selectOption(placement); + await organizer.getByRole("button", { name: "Insert PDF…" }).click(); + await picker.setInputFiles({ name: "Cover.pdf", mimeType: "application/pdf", buffer: Buffer.from(labeledPdf(["Cover"])) }); + await sidebar.getByRole("button", { name: `Page 1 from Cover.pdf, position ${expected}`, exact: true }).waitFor(); + await organizer.getByRole("button", { name: "Undo", exact: true }).click(); + await sidebar.getByRole("button", { name: /from Cover.pdf/ }).waitFor({ state: "detached" }); + } + await organizer.evaluate((element, bytes) => { + const dataTransfer = new DataTransfer(); + for (const name of ["First dropped.pdf", "Second dropped.pdf"]) { + dataTransfer.items.add(new File([Uint8Array.from(bytes)], name, { type: "application/pdf" })); + } + element.dispatchEvent(new DragEvent("drop", { dataTransfer, bubbles: true, cancelable: true })); + }, Array.from(Buffer.from(labeledPdf(["Dropped page"])))); + await sidebar.getByRole("button", { name: "Page 1 from Second dropped.pdf, position 8", exact: true }).waitFor(); + await organizer.getByRole("button", { name: "Undo", exact: true }).click(); + const protectedPdf = readFileSync(new URL("./fixtures/protected-organizer.pdf", import.meta.url)); + await picker.setInputFiles({ name: "Protected.pdf", mimeType: "application/pdf", buffer: protectedPdf }); + await organizer.getByRole("alert").filter({ hasText: "Protected PDFs cannot be inserted." }).waitFor(); + assert.equal(await imported.count(), 2); + await organizer.getByRole("button", { name: "Cancel", exact: true }).click(); + // The single-page AES-256 fixture was made with PDFium EPDF_SetEncryption. Rotation must preserve its password. + await app.evaluate(({ BrowserWindow }, data) => BrowserWindow.getAllWindows()[0].webContents.send( + "folio:open-file", { name: "Protected.pdf", data: Buffer.from(data) }), Array.from(protectedPdf)); + await page.locator('input[type=password]').fill("folio-test-reader"); + await page.locator('input[type=password]').press("Enter"); + await page.window.waitForFunction(() => document.title.startsWith("Protected.pdf")); + if (await toggle.getAttribute("aria-pressed") !== "true") await toggle.click(); + await page.getByRole("tab", { name: "Pages", exact: true }).click(); + await sidebar.getByRole("button", { name: "Organize pages" }).click(); + assert.ok(await organizer.getByRole("button", { name: "Insert PDF…" }).isDisabled()); + await organizer.getByRole("button", { name: "Rotate left" }).click(); + const protectedCopy = path.join(folder, "protected-rotated.pdf"); + await captureDownload(app, protectedCopy); + await organizer.getByRole("button", { name: "Save organized copy" }).click(); + await organizer.getByRole("button", { name: "Open organized copy" }).waitFor(); + assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed"); + const protection = await page.evaluate(async bytes => { + const open = password => pdfjsLib.getDocument({ data: Uint8Array.from(bytes), password }); + const locked = open(undefined); + try { await locked.promise; return "missing password protection"; } + catch (err) { if (err.name !== "PasswordException") throw err; } + finally { await locked.destroy(); } + const unlocked = open("folio-test-reader"); + try { return (await (await unlocked.promise).getPage(1)).rotate; } + finally { await unlocked.destroy(); } + }, Array.from(readFileSync(protectedCopy))); + assert.equal(protection, 270); + await organizer.getByRole("button", { name: "Done", exact: true }).click(); + console.log("ok PDF insertion, selection, rotation, history, and password protection"); } export async function checkReaderTools(app, page, folder, shots) { diff --git a/src/App.tsx b/src/App.tsx index 893210d..0f8ad18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -113,17 +113,18 @@ interface OpenDoc { } /** The open document's pages that reach a copy of `pages`, zero-based like pdf.js's pageIndex. */ -const keptIndexes = (pages: DraftPage[]) => new Set(pages.map(p => p.page - 1)); +const keptIndexes = (pages: DraftPage[]) => new Set(pages.filter(p => !p.source).map(p => p.page - 1)); function changesForCopy(doc: OpenDoc, annotated: boolean, pages?: DraftPage[]) { const changes = [...(doc.edits?.changes ?? [])]; if (annotated && !changes.some(change => change.kind === "annotations")) changes.push({ kind: "annotations" }); if (pages) { - const order = pages.map(p => p.page); - const rotated = pages.filter(p => p.rotation).map(p => p.page); + const order = pages.filter(p => !p.source).map(p => p.page); + const inserted = pages.filter(p => p.source).length; + const rotated = pages.filter(p => !p.source && p.rotation).map(p => p.page); const kept = new Set(order); const deleted = Array.from({ length: doc.pdf.numPages }, (_, i) => i + 1).filter(n => !kept.has(n)); - changes.push({ kind: "pages", order, ...(rotated.length && { rotated }), ...deleted.length && { deleted } }); + changes.push({ kind: "pages", order, ...(inserted && { inserted }), ...(rotated.length && { rotated }), ...deleted.length && { deleted } }); } return changes; } @@ -237,6 +238,29 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const nextSelection = useRef(null); const [editorDirty, setEditorDirty] = useState(false); const [pageDraft, setPageDraft] = useState(null); + const insertedPdfs = useRef(new Set()); + useEffect(() => { + const owned = insertedPdfs.current; + if (!owned.size) return; + const retained = new Set(); + if (pageDraft) { + for (const pages of [pageDraft.pages, ...pageDraft.undo, ...pageDraft.redo]) { + for (const page of pages) if (page.source) retained.add(page.source.pdf); + } + } + for (const pdf of owned) { + if (retained.has(pdf)) continue; + owned.delete(pdf); + void pdf.loadingTask.destroy().catch(() => {}); + } + }, [pageDraft]); + useEffect(() => { + const owned = insertedPdfs.current; + return () => { + for (const pdf of owned) void pdf.loadingTask.destroy().catch(() => {}); + owned.clear(); + }; + }, []); const organized = pageDraft && doc && draftChanged(pageDraft, doc.pdf.numPages) ? pageDraft.pages : undefined; const keptOrganized = useMemo(() => organized && keptIndexes(organized), [organized]); const unsaved = !!doc?.sourceId || dirty || editorDirty || (!!organized && !pageDraft?.copy); @@ -492,7 +516,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const reading = previous ? markupRef.current!.state : undefined; if (reading?.bookmarkedPages && exportedPages) { const bookmarks = new Set(reading.bookmarkedPages); - reading.bookmarkedPages = exportedPages.flatMap((p, i) => bookmarks.has(p.page) ? [i + 1] : []); + reading.bookmarkedPages = exportedPages.flatMap((p, i) => !p.source && bookmarks.has(p.page) ? [i + 1] : []); } persistRef.current = !password && !revision; const markupReady = markupRef.current!.load(id, persistRef.current, reading); @@ -973,7 +997,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const copyName = organized && doc ? doc.name.replace(/\.pdf$/i, "") + " (organized).pdf" : doc?.name ?? "document.pdf"; // Where the reader's page ended up in the organized copy; the first page if it was deleted. - const organizedPage = (n: number) => organized ? Math.max(1, organized.findIndex(p => p.page === n) + 1) : n; + const organizedPage = (n: number) => organized ? Math.max(1, organized.findIndex(p => !p.source && p.page === n) + 1) : n; const saveBytes = (bytes: Uint8Array) => { saveBlob(pdfBlob(bytes), copyName.toLowerCase().endsWith(".pdf") ? copyName : `${copyName}.pdf`); }; @@ -1020,7 +1044,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs try { const copy = await currentBytes(doc, organized); if (!copy || docRef.current !== doc) return; - const withText = await copyWithText(ocr, copy.bytes, organized?.map(p => p.page)); + const withText = await copyWithText(ocr, copy.bytes, organized?.map(p => p.source ? 0 : p.page)); if (docRef.current !== doc) return; const name = doc.sourceId ? copyName.replace(/\.pdf$/i, "") + " (edited).pdf" : copyName; const blob = pdfBlob(withText.bytes); @@ -1330,7 +1354,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs if (!hasFiles(e)) return; e.preventDefault(); depth++; - setDragging(true); + setDragging(!(e.dataTransfer?.types.includes("Files") && (e.target as Element)?.closest?.(".sidebar-organizer"))); }; const leave = (e: DragEvent) => { if (!hasFiles(e)) return; @@ -1345,6 +1369,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs e.preventDefault(); depth = 0; setDragging(false); + if (e.dataTransfer?.files.length && (e.target as Element)?.closest?.(".sidebar-organizer")) return; const files = e.dataTransfer?.files; const url = !files?.length && e.dataTransfer ? droppedUrl(e.dataTransfer) : null; if (files?.length) openFiles(files); @@ -1506,6 +1531,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs onToggleBookmark={n => markupRef.current!.toggleBookmark(n)} draft={pageDraft} onDraft={setPageDraft} + onImport={source => insertedPdfs.current.add(source)} onSaveDraft={download} onExportPages={exportPages} onOpenCopy={pageDraft?.copy ? () => { diff --git a/src/Sidebar.tsx b/src/Sidebar.tsx index c68a02d..4bc7ca5 100644 --- a/src/Sidebar.tsx +++ b/src/Sidebar.tsx @@ -1,6 +1,7 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { renderThumbnail, type OutlineItem, type PDFDocumentProxy } from "./pdf"; +import { isEncrypted, loadPdf, renderThumbnail, type OutlineItem, type PDFDocumentProxy } from "./pdf"; +import { checkPageCount, readPdfBlob } from "./pdfInput"; import { Icon } from "./icons"; import { deletePages, draftChanged, movePage, pageKey, rotatePages, startDraft, stepDraft, withPages, type DraftPage, type PageDraft } from "./organize"; import { IS_MAC, keys } from "./platform"; @@ -16,6 +17,7 @@ interface Props { onSelectDest: (dest: unknown) => void; draft: PageDraft | null; onDraft: (draft: PageDraft | null) => void; + onImport: (pdf: PDFDocumentProxy) => void; onSaveDraft: () => Promise; onExportPages: (pages: number[]) => Promise; onOpenCopy?: () => void; @@ -27,9 +29,9 @@ interface Props { export type SidebarTab = "pages" | "outline" | "notes"; -type PageAction = "delete" | "left" | "right"; +type PageAction = "delete" | "left" | "right" | "insert"; -export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft, onDraft, onSaveDraft, onOpenCopy, onExportPages, +export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft, onDraft, onImport, onSaveDraft, onOpenCopy, onExportPages, tab, onTab: setTab, notes, noteCount, bookmarkedPages, onToggleBookmark }: Props) { const [outline, setOutline] = useState(null); const [bookmarkedOnly, setBookmarkedOnly] = useState(false); @@ -40,6 +42,54 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft pdf.getOutline().then((o) => setOutline((o as OutlineItem[] | null) ?? [])); }, [pdf]); + const fileInput = useRef(null); + const insertionIndex = useRef(0); + const importRequest = useRef(null); + const [insertAllowed, setInsertAllowed] = useState(false); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(""); + const [importedKeys, setImportedKeys] = useState([]); + useEffect(() => { + let current = true; + isEncrypted(pdf).then(protectedPdf => { if (current) setInsertAllowed(!protectedPdf); }, () => {}); + return () => { current = false; }; + }, [pdf]); + useEffect(() => () => importRequest.current?.abort(), [pdf, draft]); + const chooseFile = (index: number) => { + insertionIndex.current = index; + fileInput.current?.click(); + }; + const insertFiles = async (files: File[], index: number) => { + if (!draft || !files.length || !insertAllowed || importRequest.current) return; + const request = new AbortController(); + importRequest.current = request; + setImporting(true); + setImportError(""); + const opened: PDFDocumentProxy[] = []; + const added: DraftPage[] = []; + try { + for (const file of files) { + const source = await loadPdf(await readPdfBlob(file, request.signal), request.signal); + opened.push(source); + if (await isEncrypted(source)) throw new Error("Protected PDFs cannot be inserted."); + checkPageCount(draft.pages.length + added.length + source.numPages); + const entry = { pdf: source, name: file.name }; + for (let page = 1; page <= source.numPages; page++) added.push({ page, source: entry }); + } + request.signal.throwIfAborted(); + for (const source of opened) onImport(source); + opened.length = 0; + setImportedKeys(added.map(pageKey)); + onDraft(withPages(draft, [...draft.pages.slice(0, index), ...added, ...draft.pages.slice(index)])); + } catch (error) { + if (!request.signal.aborted) setImportError(error instanceof Error && error.name === "PasswordException" + ? "Protected PDFs cannot be inserted." : error instanceof Error ? error.message : "Couldn’t insert that PDF."); + } finally { + for (const source of opened) void source.loadingTask.destroy().catch(() => {}); + if (importRequest.current === request) { importRequest.current = null; setImporting(false); } + } + }; + // The thumbnail menu opens the organizer with its change already made, so Undo can take it back. const organize = (pages?: number[], action?: PageAction) => { const start = startDraft(pdf.numPages); @@ -52,15 +102,22 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft } else if (pick && (action === "left" || action === "right")) { next = withPages(start, rotatePages(start.pages, new Set(pick), action === "left" ? -90 : 90)); } + setImportError(""); + setImportedKeys([]); setOrganizerPick(pick); + if (action === "insert") chooseFile(pages?.at(-1) ?? start.pages.length); onDraft(next); }; - if (draft) return ( - - ); - return ( + return <> + { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ""; + void insertFiles(files, insertionIndex.current); + }} /> + {draft ? : ( - ); + )} + ; } function OutlineNode({ item, onSelect, depth }: { item: OutlineItem; onSelect: (d: unknown) => void; depth: number }) { @@ -166,17 +223,28 @@ function extendSelection(e: ReactMouseEvent, key: string, keys: readonly string[ return next; } -function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave, onOpenCopy, initialPick }: ThumbnailProps & { +function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave, onOpenCopy, initialPick, importedKeys, insertAllowed, importing, importError, onChooseFile, onInsert }: ThumbnailProps & { draft: PageDraft; onDraft: Props["onDraft"]; onSave: Props["onSaveDraft"]; onOpenCopy?: () => void; initialPick: string[] | null; + importedKeys: string[]; + insertAllowed: boolean; + importing: boolean; + importError: string; + onChooseFile: (index: number) => void; + onInsert: (files: File[], index: number) => Promise; }) { const ref = useRef(null); const [picked, setPicked] = useState(() => new Set(initialPick ?? [String(page)])); const [anchor, setAnchor] = useState(() => initialPick?.at(-1) ?? String(page)); - const [saving, setSaving] = useState(false); + const [writingCopy, setSaving] = useState(false); + const saving = writingCopy || importing; + const [placement, setPlacement] = useState("after"); + useEffect(() => { + if (importedKeys.length) { setPicked(new Set(importedKeys)); setAnchor(importedKeys.at(-1)!); } + }, [importedKeys]); // A thumbnail-menu action replaces the thumbnail that had focus. Focus its page here once the list draws it. useEffect(() => { const key = initialPick?.at(-1); @@ -196,6 +264,8 @@ function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave, const activeKey = anchor !== null && picked.has(anchor) && rowKeys.includes(anchor) ? anchor : selected[0]; const position = activeKey === undefined ? 0 : rowKeys.indexOf(activeKey) + 1; const active = rows[position - 1] as ThumbRow | undefined; + const insertion = placement === "start" ? 0 : placement === "end" || !position ? rows.length + : placement === "before" ? position - 1 : position; const changed = draftChanged(draft, pdf.numPages); const canDelete = !saving && selected.length > 0 && selected.length < rows.length; @@ -208,7 +278,8 @@ function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave, if (!e.shiftKey) setAnchor(key); } else { choose([key]); - onSelect(rows[rowKeys.indexOf(key)].page); + const row = rows[rowKeys.indexOf(key)]; + if (!row.source) onSelect(row.page); } }; const move = (key: string, to: number) => { @@ -216,7 +287,8 @@ function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave, if (!next) return; change(next); choose([key]); - onSelect(rows[rowKeys.indexOf(key)].page); + const row = rows[rowKeys.indexOf(key)]; + if (!row.source) onSelect(row.page); }; const remove = () => { if (!canDelete) return; @@ -263,7 +335,13 @@ function PageOrganizer({ pdf, page, rotation, onSelect, draft, onDraft, onSave, }, []); return ( -