From da6109ef61e12f4f86c523f9d2d60ebf5eaf89d2 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:32:12 -0800 Subject: [PATCH 1/2] Rotate selected pages in an organized PDF copy --- README.md | 6 +++--- scripts/smoke-reader-tools.mjs | 12 +++++++++-- src/App.tsx | 5 +++-- src/Sidebar.tsx | 29 +++++++++++++++++++------ src/VersionHistoryPanel.tsx | 1 + src/contentEditor.worker.ts | 39 +++++++++++++++++++++++++--------- src/icons.tsx | 1 + src/organize.ts | 8 +++++-- src/pdf.ts | 26 ++++++++++++++++++++--- src/versionHistory.ts | 2 +- 10 files changed, 99 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a899282..21abf88 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ 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. -- **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 +- **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 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/smoke-reader-tools.mjs b/scripts/smoke-reader-tools.mjs index f2b0567..d635f5a 100644 --- a/scripts/smoke-reader-tools.mjs +++ b/scripts/smoke-reader-tools.mjs @@ -829,7 +829,7 @@ async function readCopy(page, file) { 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() }); + pages.push({ rotation: p.rotate, text: (await p.getTextContent()).items.map(i => i.str).join(" ").trim() }); } return pages; } finally { await task.destroy(); } @@ -898,6 +898,13 @@ async function checkOrganizePages(app, page, folder, shots) { await organizer.getByRole("button", { name: "Move", exact: true }).click(); assert.deepEqual(await shown(), [4, 1, 3]); + await thumb(4).click(); + await thumb(1).click({ modifiers: ["ControlOrMeta"] }); + await sidebar.screenshot({ path: path.join(shots, "organizer-before-rotation.png") }); + await organizer.getByRole("button", { name: "Rotate right", exact: true }).click(); + await organizer.getByRole("button", { name: "Rotate left", exact: true }).click(); + await organizer.getByRole("button", { name: "Undo", exact: true }).click(); + await sidebar.screenshot({ path: path.join(shots, "organizer-after-rotation.png") }); const file = path.join(folder, "organized.pdf"); await captureDownload(app, file); await organizer.getByRole("button", { name: "Save organized copy" }).click(); @@ -905,6 +912,7 @@ async function checkOrganizePages(app, page, folder, shots) { 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"]); + assert.deepEqual(copy.map(p => p.rotation), [90, 90, 0]); 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"); @@ -1102,7 +1110,7 @@ export async function checkReaderTools(app, page, folder, shots) { 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(" "), + pages.push({ rotation: p.rotate, text: (await p.getTextContent()).items.map(i => i.str).join(" "), marks: (await p.getAnnotations()).map(a => a.subtype) }); } const [bookmark] = await pdf.getOutline(); diff --git a/src/App.tsx b/src/App.tsx index 7e9314d..893210d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -120,9 +120,10 @@ function changesForCopy(doc: OpenDoc, annotated: boolean, pages?: DraftPage[]) { 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 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 } }); + changes.push({ kind: "pages", order, ...(rotated.length && { rotated }), ...deleted.length && { deleted } }); } return changes; } @@ -962,7 +963,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs // 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 }; + if (pages?.length) return { bytes: await copyPdfPages(doc.pdf, pages, 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); diff --git a/src/Sidebar.tsx b/src/Sidebar.tsx index 0bdf007..c68a02d 100644 --- a/src/Sidebar.tsx +++ b/src/Sidebar.tsx @@ -2,7 +2,7 @@ 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 { deletePages, draftChanged, movePage, pageKey, startDraft, stepDraft, withPages, type DraftPage, type PageDraft } from "./organize"; +import { deletePages, draftChanged, movePage, pageKey, rotatePages, 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"; @@ -27,7 +27,7 @@ interface Props { export type SidebarTab = "pages" | "outline" | "notes"; -type PageAction = "delete"; +type PageAction = "delete" | "left" | "right"; export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft, onDraft, onSaveDraft, onOpenCopy, onExportPages, tab, onTab: setTab, notes, noteCount, bookmarkedPages, onToggleBookmark }: Props) { @@ -49,6 +49,8 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft const result = deletePages(start.pages, new Set(pick)); next = withPages(start, result.pages); pick = [pageKey(result.next)]; + } else if (pick && (action === "left" || action === "right")) { + next = withPages(start, rotatePages(start.pages, new Set(pick), action === "left" ? -90 : 90)); } setOrganizerPick(pick); onDraft(next); @@ -97,7 +99,7 @@ export function Sidebar({ pdf, page, rotation, onSelectPage, onSelectDest, draft )} - {tab !== "notes" && pdf.numPages > 1 && ( + {tab !== "notes" && (
+ +