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" && (