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..8ecebaa 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(); }
@@ -846,7 +846,12 @@ async function checkOrganizePages(app, page, folder, shots) {
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 expectShown = async expected => {
+ await page.waitForFunction(order => {
+ const thumbs = document.querySelectorAll("aside.sidebar .thumb");
+ return thumbs.length === order.length && order.every((n, i) => Number(thumbs[i].dataset.page) === n);
+ }, expected);
+ };
const picked = () => sidebar.locator('.thumb[aria-pressed="true"]').evaluateAll(els => els.map(el => Number(el.dataset.page)));
const modifier = process.platform === "darwin" ? "Meta" : "Control";
@@ -863,10 +868,10 @@ async function checkOrganizePages(app, page, folder, shots) {
await menuDelete.click();
const organizer = page.getByRole("complementary", { name: "Organize pages" });
await organizer.waitFor();
- assert.deepEqual(await shown(), [1, 4]);
+ await expectShown([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]);
+ await expectShown([1, 2, 3, 4]);
assert.deepEqual(await picked(), [2, 3]);
// Every page selected: nothing can be deleted, by button or key.
@@ -875,7 +880,7 @@ async function checkOrganizePages(app, page, folder, shots) {
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 expectShown([1, 2, 3, 4]);
await thumb(3).click({ modifiers: [modifier] });
await thumb(1).click({ modifiers: [modifier] });
assert.deepEqual(await picked(), [2, 4]);
@@ -883,21 +888,28 @@ async function checkOrganizePages(app, page, folder, shots) {
// 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 expectShown([1, 3]);
await page.keyboard.press("ControlOrMeta+z");
- assert.deepEqual(await shown(), [1, 2, 3, 4]);
+ await expectShown([1, 2, 3, 4]);
await page.keyboard.press("ControlOrMeta+Shift+z");
- assert.deepEqual(await shown(), [1, 3]);
+ await expectShown([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 expectShown([1, 3, 4]);
// Dragging is covered by the reorder check; dragging here hangs Playwright on Windows CI.
await thumb(4).click();
await organizer.getByLabel("Move page 4 to").fill("1");
await organizer.getByRole("button", { name: "Move", exact: true }).click();
- assert.deepEqual(await shown(), [4, 1, 3]);
+ await expectShown([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,8 +917,9 @@ 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]);
+ 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");
}
@@ -1102,7 +1115,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" && (