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