From c02c60719763a7d0606e9e2e554fd35042f14a74 Mon Sep 17 00:00:00 2001 From: John Oswalt Date: Thu, 5 Nov 2026 21:49:11 -0600 Subject: [PATCH 1/4] Delete pages in a new Organize pages mode Reorder pages becomes Organize pages. Select several pages with Ctrl-click (Cmd-click on macOS) or Shift-click and delete them with the toolbar or the Delete key; Ctrl+Z and Ctrl+Shift+Z undo and redo every change until the copy is saved. The Pages tab's thumbnail menu can start with the selected pages deleted. A PDF always keeps at least one page. Built with Claude in Cursor Co-authored-by: Cursor --- README.md | 7 +- scripts/smoke-reader-tools.mjs | 118 +++++++++++- scripts/smoke-test.mjs | 4 +- src/App.tsx | 69 ++++--- src/Dialogs.tsx | 2 + src/Sidebar.tsx | 322 +++++++++++++++++++++++---------- src/VersionHistoryPanel.tsx | 18 +- src/icons.tsx | 1 + src/organize.ts | 45 +++++ src/styles.css | 3 + src/versionHistory.ts | 2 +- 11 files changed, 456 insertions(+), 135 deletions(-) create mode 100644 src/organize.ts diff --git a/README.md b/README.md index eb2c60b..8113e5c 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,10 @@ URL opening, webpage conversion, and Word conversion require the desktop app. Fa leaving your current page. Turn on **Bookmarked** at the top of the sidebar to see just your saved pages, then select a thumbnail to jump to it. These bookmarks are included in Markdown and JSON exports. Folio remembers them locally when you reopen a PDF, except for password-protected documents. -- **Reorder pages** from either sidebar tab. Drag thumbnails or enter a new position, then save and open the reordered PDF copy. +- **Organize pages** from either sidebar tab. Drag thumbnails or enter a new position, and delete pages: Ctrl-click + (⌘-click on macOS) or Shift-click to select several, then press **Delete**. Undo and redo with `Ctrl+Z` and + `Ctrl+Shift+Z`. Right-click selected thumbnails in **Pages** to start with those pages deleted. 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 and match counts, and move between matches with `Enter` / `Shift+Enter`. @@ -241,6 +244,8 @@ On macOS, use `⌘` wherever this table says `Ctrl`. Press `?` with a document o | Zoom in / zoom out / reset zoom | `Ctrl++` / `Ctrl+-` / `Ctrl+0` | | Toggle sidebar | `Ctrl+\` | | Rotate / toggle two-page view | `R` / `2` | +| In Organize pages: delete the selected pages | `Delete` or `Backspace` | +| In Organize pages: undo / redo | `Ctrl+Z` / `Ctrl+Shift+Z` or `Ctrl+Y` | | First page / last page | `Home` / `End` | | Focus mode / leave focus or current tool | `F` / `Esc` | | Settings / keyboard help | `Ctrl+,` / `?` | diff --git a/scripts/smoke-reader-tools.mjs b/scripts/smoke-reader-tools.mjs index ab852b2..629350e 100644 --- a/scripts/smoke-reader-tools.mjs +++ b/scripts/smoke-reader-tools.mjs @@ -660,6 +660,105 @@ async function checkLinkHistory(app, page, shots) { console.log("ok internal links and outlines support a one-time Back hint, contextual More commands, per-tab position and zoom, shortcuts and forward branching"); } +// One labeled page per entry, so a copy's pages can be told apart by their text. +function labeledPdf(labels) { + const objects = ["<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Count ${labels.length} /Kids [${labels.map((_, i) => `${4 + 2 * i} 0 R`).join(" ")}] >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"]; + for (const label of labels) { + const text = `BT /F 24 Tf 40 300 Td (${label}) Tj ET`; + objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /Font << /F 3 0 R >> >> /Contents ${objects.length + 2} 0 R >>`, + `<< /Length ${text.length} >>\nstream\n${text}\nendstream`); + } + return testPdf(objects); +} + +async function readCopy(page, file) { + return page.evaluate(async bytes => { + const task = pdfjsLib.getDocument({ data: Uint8Array.from(bytes) }); + try { + const pdf = await task.promise; + const pages = []; + for (let n = 1; n <= pdf.numPages; n++) { + const p = await pdf.getPage(n); + pages.push({ text: (await p.getTextContent()).items.map(i => i.str).join(" ").trim() }); + } + return pages; + } finally { await task.destroy(); } + }, Array.from(readFileSync(file))); +} + +async function checkOrganizePages(app, page, folder, shots) { + const labels = ["First page", "Second page", "Third page", "Fourth page"]; + await app.evaluate(({ BrowserWindow }, pdf) => BrowserWindow.getAllWindows()[0].webContents.send( + "folio:open-file", { name: "Organize.pdf", data: Buffer.from(pdf) }), labeledPdf(labels)); + await page.window.waitForFunction(() => document.title.startsWith("Organize.pdf")); + const toggle = page.getByRole("button", { name: /Toggle sidebar/ }); + if (await toggle.getAttribute("aria-pressed") !== "true") await toggle.click(); + await page.getByRole("tab", { name: "Pages", exact: true }).click(); + const sidebar = page.locator("aside.sidebar"); + const thumb = n => sidebar.locator(`.thumb[data-page="${n}"]`); + const shown = () => sidebar.locator(".thumb").evaluateAll(els => els.map(el => Number(el.dataset.page))); + const picked = () => sidebar.locator('.thumb[aria-pressed="true"]').evaluateAll(els => els.map(el => Number(el.dataset.page))); + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + + // The thumbnail menu opens the organizer with its change made; Undo brings the pages back, selected. + await thumb(2).click(); + await thumb(3).click({ modifiers: ["Shift"] }); + await thumb(3).click({ button: "right" }); + const menuDelete = page.getByRole("menuitem", { name: "Delete 2 pages" }); + await menuDelete.waitFor(); + const panel = await sidebar.boundingBox(), popup = await page.getByRole("menu", { name: "Page actions" }).boundingBox(); + await page.window.screenshot({ path: path.join(shots, "organize-pages-menu.png"), clip: { + x: panel.x, y: panel.y, width: Math.max(panel.width, popup.x + popup.width + 10 - panel.x), height: panel.height, + } }); + await menuDelete.click(); + const organizer = page.getByRole("complementary", { name: "Organize pages" }); + await organizer.waitFor(); + assert.deepEqual(await shown(), [1, 4]); + await page.waitForFunction(() => document.activeElement?.matches('aside .thumb[data-key="4"]')); + await organizer.getByRole("button", { name: "Undo", exact: true }).click(); + assert.deepEqual(await shown(), [1, 2, 3, 4]); + assert.deepEqual(await picked(), [2, 3]); + + // Every page selected: nothing can be deleted, by button or key. + await thumb(1).click(); + await thumb(4).click({ modifiers: ["Shift"] }); + assert.deepEqual(await picked(), [1, 2, 3, 4]); + assert.ok(await organizer.getByRole("button", { name: "Delete 4 pages" }).isDisabled()); + await page.keyboard.press("Delete"); + assert.deepEqual(await shown(), [1, 2, 3, 4]); + await thumb(3).click({ modifiers: [modifier] }); + await thumb(1).click({ modifiers: [modifier] }); + assert.deepEqual(await picked(), [2, 4]); + await sidebar.screenshot({ path: path.join(shots, "organize-pages-selected.png") }); + + // Delete and Backspace delete; the shortcuts undo and redo inside the organizer, not the reader's edits. + await page.keyboard.press("Delete"); + assert.deepEqual(await shown(), [1, 3]); + await page.keyboard.press("ControlOrMeta+z"); + assert.deepEqual(await shown(), [1, 2, 3, 4]); + await page.keyboard.press("ControlOrMeta+Shift+z"); + assert.deepEqual(await shown(), [1, 3]); + await page.keyboard.press("ControlOrMeta+z"); + await thumb(2).click(); + await page.keyboard.press("Backspace"); + assert.deepEqual(await shown(), [1, 3, 4]); + await thumb(4).dragTo(thumb(1)); + assert.deepEqual(await shown(), [4, 1, 3]); + + const file = path.join(folder, "organized.pdf"); + await captureDownload(app, file); + 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 copy = await readCopy(page, file); + assert.deepEqual(copy.map(p => p.text), ["Fourth page", "First page", "Third page"]); + await organizer.getByRole("button", { name: "Done", exact: true }).click(); + assert.deepEqual(await shown(), [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"); +} + export async function checkReaderTools(app, page, folder, shots) { const settings = () => page.keyboard.press(process.platform === "darwin" ? "Meta+Comma" : "Control+Comma"); const wheelSwitch = page.getByRole("switch", { name: "Scroll one page at a time" }); @@ -828,7 +927,7 @@ export async function checkReaderTools(app, page, folder, shots) { assert.equal(marks.length, 2); assert.ok(marks[0].includes("Highlight") && marks[0].includes("FreeText") && marks[1].includes("Highlight")); await page.getByRole("tab", { name: "Outline" }).click(); - await page.getByRole("button", { name: "Reorder pages" }).click(); + await page.getByRole("button", { name: "Organize pages" }).click(); await thumb(1).dragTo(thumb(2)); assert.equal(await thumb(1).getAttribute("aria-label"), "Page 1, position 2"); await page.getByLabel("Move page 1 to").fill("3"); @@ -836,8 +935,8 @@ export async function checkReaderTools(app, page, folder, shots) { assert.equal(await thumb(1).getAttribute("aria-label"), "Page 1, position 3"); const file = path.join(folder, "reordered.pdf"); await captureDownload(app, file); - await page.getByRole("button", { name: "Save reordered copy" }).click(); - const openCopy = page.getByRole("button", { name: "Open reordered copy" }); + await page.getByRole("button", { name: "Save organized copy" }).click(); + const openCopy = page.getByRole("button", { name: "Open organized copy" }); await openCopy.waitFor(); assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed"); const result = await page.evaluate(async bytes => { @@ -866,8 +965,8 @@ export async function checkReaderTools(app, page, folder, shots) { assert.ok(reordered.pages[2].marks.includes("FreeText") && reordered.pages[2].marks.includes("Highlight")); assert.ok(reordered.pages.at(-1).marks.includes("Highlight")); await openCopy.click(); - await page.window.waitForFunction(() => document.title.includes("(reordered).pdf")); - console.log("ok reordered PDF pages, bookmark destinations and annotations match the sidebar order"); + await page.window.waitForFunction(() => document.title.includes("(organized).pdf")); + console.log("ok organized PDF pages, bookmark destinations and annotations match the sidebar order"); await app.evaluate(({ BrowserWindow }, form) => BrowserWindow.getAllWindows()[0].webContents.send( "folio:open-file", { name: "Form.pdf", data: Buffer.from(form) }), formPdf()); @@ -892,21 +991,22 @@ export async function checkReaderTools(app, page, folder, shots) { } finally { await task.destroy(); } }, Array.from(readFileSync(selectedForm))), "A filled field"); await documents.getByRole("button", { name: "Close Form.pdf, unsaved changes" }).waitFor(); - await page.getByRole("button", { name: "Reorder pages" }).click(); + await page.getByRole("button", { name: "Organize pages" }).click(); await page.getByLabel("Move page 1 to").fill("2"); await page.getByRole("button", { name: "Move", exact: true }).click(); - await captureDownload(app, path.join(folder, "reordered-form.pdf")); + await captureDownload(app, path.join(folder, "organized-form.pdf")); await page.keyboard.press("ControlOrMeta+s"); await openCopy.click(); assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed"); - await page.window.waitForFunction(() => document.title.startsWith("Form (reordered).pdf")); + await page.window.waitForFunction(() => document.title.startsWith("Form (organized).pdf")); await documents.getByRole("button", { name: "Close Form.pdf", exact: true }).waitFor(); await page.getByTitle("Next page").click(); assert.equal(await page.locator('.page[data-page-number="2"] .textWidgetAnnotation input').inputValue(), "A filled field"); - console.log("ok the normal Save shortcut and Open reordered copy preserve filled form fields"); + console.log("ok the normal Save shortcut and Open organized copy preserve filled form fields"); await checkScanSelection(app, page); await checkContentEditing(app, page, folder, shots); await checkLinkHistory(app, page, shots); await checkPageExport(app, page, folder, shots); + await checkOrganizePages(app, page, folder, shots); await checkPenDrawing(app, page, folder, shots); } diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 61ab01e..6b1f274 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -661,7 +661,7 @@ try { await filter.click(); // In the page organizer, a moved page takes its own shape to its new row: drag the 11x17 sheet to the top. - await page.getByRole("button", { name: "Reorder pages" }).click(); + await page.getByRole("button", { name: "Organize pages" }).click(); const organized = (n) => page.locator(`.thumb[data-page="${n}"]`); await organized(3).dragTo(organized(1)); assert.equal(await organized(3).getAttribute("aria-label"), "Page 3, position 1"); @@ -675,7 +675,7 @@ try { assert.ok(sameFrame(rows[0], frames[2]) && sameFrame(rows[1], frames[0]) && sameFrame(rows[2], frames[1]), `reordered thumbnails lost their shape: ${JSON.stringify(rows)}`); await page.getByRole("button", { name: "Cancel" }).click(); - console.log("ok reordered thumbnails keep each page's shape:", rows.map((r) => `page ${r.page} ${Math.round(r.width)}x${Math.round(r.height)}`).join(", ")); + console.log("ok organized thumbnails keep each page's shape:", rows.map((r) => `page ${r.page} ${Math.round(r.width)}x${Math.round(r.height)}`).join(", ")); // A Word document opens converted to PDF (by Word, LibreOffice, or Folio itself, whichever the computer has), // and the PDF is saved next to it. Opening the document again reuses that PDF. diff --git a/src/App.tsx b/src/App.tsx index 1f9438b..8ff976e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { EventBus, LinkTarget, @@ -12,6 +12,7 @@ import { AnnotationEditorParamsType, AnnotationEditorType } from "pdfjs-dist"; import { loadPdf, copyPdfPages, thumbnailDataUrl, type PDFDocumentProxy } from "./pdf"; import { contentKey, getRecent, MAX_RECENT_FILE_BYTES, onStoredDataCleared, removeRecent, saveRecent, updateLastPage } from "./recent"; import { readPdfBlob, readPdfResponse } from "./pdfInput"; +import { draftChanged, type DraftPage, type PageDraft } from "./organize"; import { FIT_SCALES, readView, writeView, type ReadingView } from "./session"; import { readSetting, writeSetting } from "./preferences"; import { DocumentTabBar } from "./DocumentTabBar"; @@ -97,7 +98,7 @@ interface OpenDoc { /** The file's size; its bytes live in pdf.js's worker (pdf.getData()) rather than in a second copy here. */ size: number; pdf: PDFDocumentProxy; - /** Used only in memory when reopening an annotated snapshot for page reordering. */ + /** Used only in memory when reopening an annotated snapshot for organizing pages. */ password?: string; /** Original library entry while content edits are held in memory. */ sourceId?: string; @@ -105,10 +106,18 @@ interface OpenDoc { edits?: { original?: Blob; changes: PdfChange[] }; } -function changesForCopy(doc: OpenDoc, annotated: boolean, order?: number[]) { +/** 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)); + +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 (order) changes.push({ kind: "pages", order }); + if (pages) { + const order = pages.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, ...deleted.length && { deleted } }); + } return changes; } @@ -214,9 +223,10 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const contentRef = useRef(null); const nextSelection = useRef(null); const [editorDirty, setEditorDirty] = useState(false); - const [pageDraft, setPageDraft] = useState<{ order: number[]; copy?: Uint8Array } | null>(null); - const pageOrder = pageDraft?.order.some((n, i) => n !== i + 1) ? pageDraft.order : undefined; - const unsaved = !!doc?.sourceId || dirty || editorDirty || (!!pageOrder && !pageDraft?.copy); + const [pageDraft, setPageDraft] = useState(null); + 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); const [focus, setFocus] = useState(false); const [dragging, setDragging] = useState(false); const [printCopy, setPrintCopy] = useState<{ pdf: PDFDocumentProxy; bytes: Uint8Array; page: number; name: string } | null>(null); @@ -404,7 +414,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const openBytes = useCallback( async (name: string, data: Uint8Array, password?: string, signal = beginOpen(), initialPage?: number, - edit?: { doc: OpenDoc; change: PdfChange }, exported?: OpenDoc, exportedOrder?: number[]) => { + edit?: { doc: OpenDoc; change: PdfChange }, exported?: OpenDoc, exportedPages?: DraftPage[]) => { const revision = edit?.doc; const viewer = viewerRef.current!; setLoading(true); @@ -441,9 +451,9 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs docRef.current = next; // Password-protected documents aren't stored by Folio (same rule as Recent). const reading = previous ? markupRef.current!.state : undefined; - if (reading?.bookmarkedPages && exportedOrder) { + if (reading?.bookmarkedPages && exportedPages) { const bookmarks = new Set(reading.bookmarkedPages); - reading.bookmarkedPages = exportedOrder.flatMap((n, i) => bookmarks.has(n) ? [i + 1] : []); + reading.bookmarkedPages = exportedPages.flatMap((p, i) => bookmarks.has(p.page) ? [i + 1] : []); } persistRef.current = !password && !revision; void markupRef.current!.load(id, persistRef.current, reading); @@ -468,7 +478,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs setDoc(next); if (!revision) { nextSelection.current = null; setContentEditing(false); } setEditorDirty(false); - setPageDraft(draft => revision && draft ? { order: draft.order } : null); + setPageDraft(draft => revision && draft ? { ...draft, copy: undefined } : null); setNumPages(pdf.numPages); setPage(lastPageRef.current); setPageInput(String(lastPageRef.current)); @@ -856,7 +866,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs return () => window.removeEventListener("pagehide", flush); }, []); - const currentBytes = async (doc = docRef.current, pages = pageOrder) => { + const currentBytes = async (doc = docRef.current, pages = organized) => { if (!doc || exporting.current === doc.pdf) return null; exporting.current = doc.pdf; const signal = opening.current!.signal; @@ -871,8 +881,10 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs storage.setValue(key, data); keys.push(key); } - const annotated = storage.size > 0; - if (pages?.length) return { bytes: await copyPdfPages(doc.pdf, pages, signal, doc.password), annotated }; + // Marks on deleted pages don't reach the copy. Form values don't carry their page here, so they always count. + const kept = pages && keptIndexes(pages); + const annotated = [...storage].some(([, value]) => !kept || typeof value?.pageIndex !== "number" || kept.has(value.pageIndex)); + if (pages?.length) return { bytes: await copyPdfPages(doc.pdf, pages.map(p => p.page), signal, doc.password), annotated }; return { bytes: annotated ? await doc.pdf.saveDocument() : await doc.pdf.getData(), annotated }; } finally { for (const key of keys) storage.remove(key); @@ -880,7 +892,9 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs } }; - const copyName = pageOrder && doc ? doc.name.replace(/\.pdf$/i, "") + " (reordered).pdf" : doc?.name ?? "document.pdf"; + 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 saveBytes = (bytes: Uint8Array) => { saveBlob(pdfBlob(bytes), copyName.toLowerCase().endsWith(".pdf") ? copyName : `${copyName}.pdf`); }; @@ -890,7 +904,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const source = docRef.current; if (!source || (source.sourceId ?? source.id) !== (doc?.sourceId ?? doc?.id)) return; try { - const copy = await currentBytes(source, pages); + const copy = await currentBytes(source, pages.map(page => ({ page }))); if (docRef.current !== source) return; if (!copy) { flash("Another export is in progress. Try again in a moment."); return; } saveBlob(pdfBlob(copy.bytes), source.name.replace(/\.pdf$/i, "") + " (selected pages).pdf"); @@ -923,13 +937,13 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs savingCopy.current = true; const revision = editRevision.current; try { - const copy = await currentBytes(doc, pageOrder); + const copy = await currentBytes(doc, organized); if (!copy || docRef.current !== doc) return; const name = doc.sourceId ? copyName.replace(/\.pdf$/i, "") + " (edited).pdf" : copyName; const blob = pdfBlob(copy.bytes); saveBlob(blob, /\.pdf$/i.test(name) ? name : name + ".pdf"); if (editRevision.current === revision) setDirty(false); - const changes = changesForCopy(doc, copy.annotated, pageOrder); + const changes = changesForCopy(doc, copy.annotated, organized); let historyMessage = ""; if (changes.length && doc.historyAllowed && blob.size <= MAX_RECENT_FILE_BYTES) { try { @@ -943,10 +957,10 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs if (docRef.current !== doc) return; setError(null); if (doc.sourceId) { - await openBytes(name, copy.bytes, undefined, beginOpen(), pageOrder ? pageOrder.indexOf(pageRef.current) + 1 : pageRef.current, undefined, doc, pageOrder); - } else if (pageOrder) { - setPageDraft(draft => draft?.order === pageOrder ? { order: pageOrder, copy: copy.bytes } : draft); - flash("Downloading reordered copy"); + await openBytes(name, copy.bytes, undefined, beginOpen(), organizedPage(pageRef.current), undefined, doc, organized); + } else if (organized) { + setPageDraft(draft => draft?.pages === organized ? { ...draft, copy: copy.bytes } : draft); + flash("Downloading organized copy"); } else { flash(copy.annotated ? "Saved a copy with your highlights and notes" : "Download started"); } @@ -971,7 +985,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs if (!window.folioDesktop) { printPdfBytes(copy.bytes); return; } const snapshot = await loadPdf(copy.bytes, opening.current!.signal, source.password); if (docRef.current !== source) { void snapshot.loadingTask.destroy(); return; } - setPrintCopy({ pdf: snapshot, bytes: copy.bytes, page: pageOrder ? pageOrder.indexOf(page) + 1 : page, name: source.name }); + setPrintCopy({ pdf: snapshot, bytes: copy.bytes, page: organizedPage(page), name: source.name }); } catch { if (docRef.current === doc) setError("Couldn't print this document."); } finally { @@ -1392,9 +1406,9 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs onSelectPage={goTo} bookmarkedPages={markup.bookmarkedPages} onToggleBookmark={n => markupRef.current!.toggleBookmark(n)} - order={pageDraft?.order ?? null} - onOrder={order => setPageDraft(order ? { order } : null)} - onSaveOrder={download} + draft={pageDraft} + onDraft={setPageDraft} + onSaveDraft={download} onExportPages={exportPages} onOpenCopy={pageDraft?.copy ? () => { tabs.open([{ kind: "bytes", name: copyName, data: pageDraft.copy!, password: doc.password }]); @@ -1583,7 +1597,8 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs {doc && historyOpen && !focus && 0, pageOrder)} + allowed={doc.historyAllowed} active={active} + pending={changesForCopy(doc, dirty || markup.marks.some(mark => !keptOrganized || keptOrganized.has(mark.page)), organized)} canOpen={id => !tabs.all.some(t => t.contentId === id && t.dirty)} onOpen={(v, blob) => tabs.open([{ kind: "blob", name: v.name, blob }])} onDownload={(v, blob) => saveBlob(blob, v.name.replace(/\.pdf$/i, "") + ` (v${v.number}).pdf`)} diff --git a/src/Dialogs.tsx b/src/Dialogs.tsx index 68ea463..76f7d34 100644 --- a/src/Dialogs.tsx +++ b/src/Dialogs.tsx @@ -139,6 +139,8 @@ const SHORTCUTS: [string, string][] = [ ["Draw", "D"], ["Add text", "T"], ["Rotate pages", "R"], + ["In Organize pages: delete the selected pages", "Delete / Backspace"], + ["In Organize pages: undo / redo", "Ctrl Z / Ctrl Shift Z"], ["Two-page view", "2"], ["Focus mode", "F"], ["First / last page", "Home / End"], diff --git a/src/Sidebar.tsx b/src/Sidebar.tsx index 8e41ed7..0bdf007 100644 --- a/src/Sidebar.tsx +++ b/src/Sidebar.tsx @@ -2,7 +2,8 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, typ import { createPortal } from "react-dom"; import { renderThumbnail, type OutlineItem, type PDFDocumentProxy } from "./pdf"; import { Icon } from "./icons"; -import { IS_MAC } from "./platform"; +import { deletePages, draftChanged, movePage, pageKey, startDraft, stepDraft, withPages, type DraftPage, type PageDraft } from "./organize"; +import { IS_MAC, keys } from "./platform"; import { anchoredScrollTop, loadPageSizes, rowAt, thumbLayout, THUMB_ROW_EXTRA, type PageSize } from "./thumbnails"; interface Props { @@ -13,9 +14,9 @@ interface Props { bookmarkedPages: number[] | null; onToggleBookmark: (n: number) => void; onSelectDest: (dest: unknown) => void; - order: number[] | null; - onOrder: (order: number[] | null) => void; - onSaveOrder: () => Promise; + draft: PageDraft | null; + onDraft: (draft: PageDraft | null) => void; + onSaveDraft: () => Promise; onExportPages: (pages: number[]) => Promise; onOpenCopy?: () => void; tab: SidebarTab; @@ -26,19 +27,36 @@ interface Props { export type SidebarTab = "pages" | "outline" | "notes"; -export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, order, onOrder, onSaveOrder, onOpenCopy, onExportPages, +type PageAction = "delete"; + +export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft, onDraft, onSaveDraft, onOpenCopy, onExportPages, tab, onTab: setTab, notes, noteCount, bookmarkedPages, onToggleBookmark }: Props) { const [outline, setOutline] = useState(null); const [bookmarkedOnly, setBookmarkedOnly] = useState(false); + const [organizerPick, setOrganizerPick] = useState(null); useEffect(() => { setOutline(null); pdf.getOutline().then((o) => setOutline((o as OutlineItem[] | null) ?? [])); }, [pdf]); - if (order) return ( + // 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); + let pick = pages?.map(String) ?? null; + let next = start; + if (action === "delete" && pick) { + const result = deletePages(start.pages, new Set(pick)); + next = withPages(start, result.pages); + pick = [pageKey(result.next)]; + } + setOrganizerPick(pick); + onDraft(next); + }; + + if (draft) return ( + draft={draft} onDraft={onDraft} onSave={onSaveDraft} onOpenCopy={onOpenCopy} initialPick={organizerPick} /> ); return (