diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 9d26a18a633ed..76926ab3c5232 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -764,6 +764,7 @@ pdfjs-views-manager-status-warning-copy-label = Couldn’t copy. Refresh page an pdfjs-views-manager-status-warning-delete-label = Couldn’t delete. Refresh page and try again. pdfjs-views-manager-status-warning-save-label = Couldn’t save. Refresh page and try again. pdfjs-views-manager-status-undo-button-label = Undo +pdfjs-views-manager-status-done-button-label = Done pdfjs-views-manager-status-close-button = .title = Close pdfjs-views-manager-status-close-button-label = Close diff --git a/src/display/api.js b/src/display/api.js index 7ac3bc8f848de..f77b572722bce 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2406,6 +2406,8 @@ class WorkerTransport { #copiedPageInfo = null; + #savedPageInfo = null; + constructor( messageHandler, loadingTask, @@ -2477,13 +2479,36 @@ class WorkerTransport { return; } + if (type === "cancelCopy") { + this.#copiedPageInfo = null; + return; + } + if (type === "delete") { + this.#savedPageInfo = { + pageCache: new Map(this.#pageCache), + pagePromises: new Map(this.#pagePromises), + }; for (const pageNum of pageNumbers) { this.#pageCache.delete(pageNum - 1); this.#pagePromises.delete(pageNum - 1); } } + if (type === "cancelDelete") { + if (this.#savedPageInfo) { + this.#pageCache = this.#savedPageInfo.pageCache; + this.#pagePromises = this.#savedPageInfo.pagePromises; + this.#savedPageInfo = null; + } + return; + } + + if (type === "cleanSavedData") { + this.#savedPageInfo = null; + return; + } + const newPageCache = new Map(); const newPromiseCache = new Map(); const { pagesMapper } = this; diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 5abf8c8dcef72..083399d111fc8 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -1082,6 +1082,8 @@ class PagesMapper { */ #copiedPageNumbers = null; + #savedData = null; + /** * Gets the total number of pages. * @returns {number} The number of pages. @@ -1253,6 +1255,13 @@ class PagesMapper { const pageNumberToId = this.#pageNumberToId; const prevIdToPageNumber = this.#idToPageNumber; + this.#savedData = { + pageNumberToId: pageNumberToId.slice(), + idToPageNumber: new Map(prevIdToPageNumber), + pageNumber: this.#pagesNumber, + prevPageNumbers: this.#prevPageNumbers.slice(), + }; + this.pagesNumber -= pagesToDelete.length; this.#init(false); const newPageNumberToId = this.#pageNumberToId; @@ -1279,6 +1288,22 @@ class PagesMapper { this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete }); } + cancelDelete() { + if (this.#savedData) { + this.#pageNumberToId = this.#savedData.pageNumberToId; + this.#idToPageNumber = this.#savedData.idToPageNumber; + this.pagesNumber = this.#savedData.pageNumber; + this.#prevPageNumbers = this.#savedData.prevPageNumbers; + this.#savedData = null; + this.#updateListeners({ type: "cancelDelete" }); + } + } + + cleanSavedData() { + this.#savedData = null; + this.#updateListeners({ type: "cleanSavedData" }); + } + /** * Copies a set of pages while keeping ID→number mappings in sync. * @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed). @@ -1292,6 +1317,12 @@ class PagesMapper { this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy }); } + cancelCopy() { + this.#copiedPageIds = null; + this.#copiedPageNumbers = null; + this.#updateListeners({ type: "cancelCopy" }); + } + /** * Pastes a set of pages while keeping ID→number mappings in sync. * @param {number} index - Zero-based insertion index in the page-number list. @@ -1323,6 +1354,7 @@ class PagesMapper { this.#updateListeners({ type: "paste" }); this.#copiedPageIds = null; + this.#copiedPageNumbers = null; } /** @@ -1455,7 +1487,7 @@ class PagesMapper { } getMapping() { - return this.#pageNumberToId.subarray(0, this.pagesNumber); + return this.#pageNumberToId?.subarray(0, this.pagesNumber); } } diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 4ff59fe3a1cce..a7a3a732c9037 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -33,6 +33,7 @@ import { showViewsManager, waitAndClick, waitForDOMMutation, + waitForTextToBe, } from "./test_utils.mjs"; async function waitForThumbnailVisible(page, pageNums) { @@ -63,7 +64,7 @@ function waitForPagesEdited(page, type) { return; } window.PDFViewerApplication.eventBus.off("pagesedited", listener); - resolve(Array.from(pagesMapper.getMapping())); + resolve(Array.from(pagesMapper.getMapping() || [])); }; window.PDFViewerApplication.eventBus.on("pagesedited", listener); }, @@ -164,7 +165,7 @@ describe("Reorganize Pages View", () => { continue; } for (const node of mutation.addedNodes) { - if (node.classList.contains("dragMarker")) { + if (node.classList?.contains("dragMarker")) { return true; } } @@ -180,7 +181,7 @@ describe("Reorganize Pages View", () => { continue; } for (const node of mutation.removedNodes) { - if (node.classList.contains("dragMarker")) { + if (node.classList?.contains("dragMarker")) { return true; } } @@ -559,7 +560,7 @@ describe("Reorganize Pages View", () => { continue; } for (const node of mutation.addedNodes) { - if (node.classList.contains("dragMarker")) { + if (node.classList?.contains("dragMarker")) { const rect = node.getBoundingClientRect(); return rect.width !== 0; } @@ -1182,6 +1183,401 @@ describe("Reorganize Pages View", () => { }); }); + describe("Status label reflects number of checked thumbnails (bug 2010832)", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + "#viewsManagerToggleButton", + "1", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should update the status label when thumbnails are checked or unchecked", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + + const labelSelector = "#viewsManagerStatusActionLabel"; + + // Initially no pages are selected. + await waitForTextToBe(page, labelSelector, "Select pages"); + + // Check thumbnail 1: label should read "1 selected". + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitForTextToBe(page, labelSelector, `${FSI}1${PDI} selected`); + + // Check thumbnail 2: label should read "2 selected". + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(2)}) input` + ); + await waitForTextToBe(page, labelSelector, `${FSI}2${PDI} selected`); + + // Uncheck thumbnail 1: label should read "1 selected". + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitForTextToBe(page, labelSelector, `${FSI}1${PDI} selected`); + + // Uncheck thumbnail 2: label should revert to "Select pages". + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(2)}) input` + ); + await waitForTextToBe(page, labelSelector, "Select pages"); + }) + ); + }); + }); + + describe("Undo label reflects number of cut/deleted pages (bug 2010832)", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + "#viewsManagerToggleButton", + "1", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should show the correct undo label after cutting one or two pages", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + + const undoLabelSelector = "#viewsManagerStatusUndoLabel"; + + // Cut 1 page and check the undo label. + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + let handlePagesEdited = await waitForPagesEdited(page, "cut"); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionCut"); + await awaitPromise(handlePagesEdited); + + await page.waitForSelector("#viewsManagerStatusUndo", { + visible: true, + }); + await waitForTextToBe(page, undoLabelSelector, "1 page cut"); + + // Undo the cut to restore the original state. + handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusUndoButton"); + await awaitPromise(handlePagesEdited); + + // Cut 2 pages and check the undo label. + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(3)}) input` + ); + handlePagesEdited = await waitForPagesEdited(page, "cut"); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionCut"); + await awaitPromise(handlePagesEdited); + + await page.waitForSelector("#viewsManagerStatusUndo", { + visible: true, + }); + await waitForTextToBe( + page, + undoLabelSelector, + `${FSI}2${PDI} pages cut` + ); + }) + ); + }); + + it("should show the correct undo label after deleting one or two pages", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + + const undoLabelSelector = "#viewsManagerStatusUndoLabel"; + + // Delete 1 page and check the undo label. + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + let handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionDelete"); + await awaitPromise(handlePagesEdited); + + await page.waitForSelector("#viewsManagerStatusUndo", { + visible: true, + }); + await waitForTextToBe(page, undoLabelSelector, "1 page deleted"); + + // Undo the deletion to restore the original state. + handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusUndoButton"); + await awaitPromise(handlePagesEdited); + + // Delete 2 pages and check the undo label. + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(3)}) input` + ); + handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionDelete"); + await awaitPromise(handlePagesEdited); + + await page.waitForSelector("#viewsManagerStatusUndo", { + visible: true, + }); + await waitForTextToBe( + page, + undoLabelSelector, + `${FSI}2${PDI} pages deleted` + ); + }) + ); + }); + }); + + describe("Closing the undo bar after a cut is equivalent to a delete (bug 2010832)", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + "#viewsManagerToggleButton", + "1", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should permanently remove the cut page when the undo bar is closed", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + + // Cut page 1. + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + let handlePagesEdited = await waitForPagesEdited(page, "cut"); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionCut"); + await awaitPromise(handlePagesEdited); + + await page.waitForSelector("#viewsManagerStatusUndo", { + visible: true, + }); + + // Close the undo bar instead of undoing. + handlePagesEdited = await waitForPagesEdited(page, "cleanSavedData"); + await waitAndClick(page, "#viewsManagerStatusUndoCloseButton"); + const pageIndices = await awaitPromise(handlePagesEdited); + + // The result must equal a plain deletion of page 1. + const expected = [ + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + ]; + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual(expected); + + await page.waitForSelector("#viewsManagerStatusUndo", { + hidden: true, + }); + + await waitForHavingContents(page, expected); + }) + ); + }); + }); + + describe("Closing the undo bar after a delete effectively deletes the page (bug 2010832)", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + "#viewsManagerToggleButton", + "1", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should permanently remove the deleted page when the undo bar is closed", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + + // Delete page 1. + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + let handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionDelete"); + await awaitPromise(handlePagesEdited); + + await page.waitForSelector("#viewsManagerStatusUndo", { + visible: true, + }); + + // Close the undo bar instead of undoing. + handlePagesEdited = await waitForPagesEdited(page, "cleanSavedData"); + await waitAndClick(page, "#viewsManagerStatusUndoCloseButton"); + const pageIndices = await awaitPromise(handlePagesEdited); + + // The page must be effectively deleted. + const expected = [ + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + ]; + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual(expected); + + await page.waitForSelector("#viewsManagerStatusUndo", { + hidden: true, + }); + + await waitForHavingContents(page, expected); + }) + ); + }); + }); + + describe("Clicking Done after copying removes paste buttons (bug 2010832)", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + "#viewsManagerToggleButton", + "1", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should show a Done button after copy and remove paste buttons when clicked", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + + // Copy page 1. + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + const handlePagesEdited = await waitForPagesEdited(page, "copy"); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionCopy"); + await awaitPromise(handlePagesEdited); + + // The undo bar must appear with a "Done" label (not "Undo"). + await page.waitForSelector("#viewsManagerStatusUndo", { + visible: true, + }); + await waitForTextToBe( + page, + "#viewsManagerStatusUndoLabel", + "1 page copied" + ); + await waitForTextToBe( + page, + "#viewsManagerStatusUndoButton span[data-l10n-id]", + "Done" + ); + + // The close button must be hidden for copy. + const closeHidden = await page.$eval( + "#viewsManagerStatusUndoCloseButton", + el => el.classList.contains("hidden") + ); + expect(closeHidden).withContext(`In ${browserName}`).toBeTrue(); + + // Paste buttons must be present. + await page.waitForSelector("button.thumbnailPasteButton"); + + // Click Done and wait for the cancelCopy pagesedited event. + const handleCancelCopy = await waitForPagesEdited(page, "cancelCopy"); + await waitAndClick(page, "#viewsManagerStatusUndoButton"); + await awaitPromise(handleCancelCopy); + + // Undo bar must be hidden and paste buttons must be gone. + await page.waitForSelector("#viewsManagerStatusUndo", { + hidden: true, + }); + await page.waitForSelector("button.thumbnailPasteButton", { + hidden: true, + }); + const pasteButtons = await page.$$("button.thumbnailPasteButton"); + expect(pasteButtons.length).withContext(`In ${browserName}`).toBe(0); + }) + ); + }); + }); + describe("Extract some pages from a pdf", () => { let pages; diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index 6f8762826d568..67f5c0e417e13 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -890,6 +890,15 @@ function waitForNoElement(page, selector) { ); } +function waitForTextToBe(page, selector, text) { + return page.waitForFunction( + (sel, str) => document.querySelector(sel)?.textContent.trim() === str, + {}, + selector, + text + ); +} + function isCanvasMonochrome(page, pageNumber, rectangle, color) { return page.evaluate( (rect, pageN, col) => { @@ -1071,6 +1080,7 @@ export { waitForSelectedEditor, waitForSerialized, waitForStorageEntries, + waitForTextToBe, waitForTimeout, waitForUnselectedEditor, }; diff --git a/web/app.js b/web/app.js index 02697b859513e..5897fdc0c1ace 100644 --- a/web/app.js +++ b/web/app.js @@ -589,8 +589,9 @@ const PDFViewerApplication = { pdfScriptingManager.setViewer(pdfViewer); if (appConfig.viewsManager?.thumbnailsView) { + const { viewsManager } = appConfig; this.pdfThumbnailViewer = new PDFThumbnailViewer({ - container: appConfig.viewsManager.thumbnailsView, + container: viewsManager.thumbnailsView, eventBus, renderingQueue, linkService, @@ -600,8 +601,10 @@ const PDFViewerApplication = { abortSignal, enableHWA, enableSplitMerge, - manageMenu: appConfig.viewsManager.manageMenu, - addFileButton: appConfig.viewsManager.viewsManagerAddFileButton, + statusBar: viewsManager.viewsManagerStatusBar, + undoBar: viewsManager.viewsManagerUndoBar, + manageMenu: viewsManager.manageMenu, + addFileButton: viewsManager.viewsManagerAddFileButton, }); renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer); } diff --git a/web/pdf_find_controller.js b/web/pdf_find_controller.js index dbbc0f19ad0f0..7084fe245a6c9 100644 --- a/web/pdf_find_controller.js +++ b/web/pdf_find_controller.js @@ -424,6 +424,8 @@ class PDFFindController { #copiedExtractTextPromises = null; + #savedExtractTextPromises = null; + /** * @param {PDFFindControllerOptions} options */ @@ -1146,10 +1148,29 @@ class PDFFindController { return; } + if (type === "cancelCopy") { + this.#copiedExtractTextPromises = null; + return; + } + + if (type === "delete") { + this.#savedExtractTextPromises = this._extractTextPromises; + } + + if (type === "cancelDelete") { + this._extractTextPromises = this.#savedExtractTextPromises; + return; + } + + if (type === "cleanSavedData") { + this.#savedExtractTextPromises = null; + return; + } + this.#onFindBarClose(); this._dirtyMatch = true; const prevTextPromises = this._extractTextPromises; - const extractTextPromises = (this._extractTextPromises.length = []); + const extractTextPromises = (this._extractTextPromises = []); for (let i = 1, ii = pagesMapper.length; i <= ii; i++) { const prevPageNumber = pagesMapper.getPrevPageNumber(i); if (prevPageNumber < 0) { diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 12e83934f81ea..8ee1cb29473f4 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -68,6 +68,10 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15; * rendering. The default value is `false`. * @property {boolean} [enableSplitMerge] - Enables split and merge features. * The default value is `false`. + * @property {Object} [statusBar] - The status bar elements to manage the status + * label and action when editing pages. + * @property {Object} [undoBar] - The undo bar elements to manage the undo + * action. * @property {Object} [manageMenu] - The menu elements to manage saving edited * PDF. * @property {HTMLButtonElement} addFileButton - The button that opens a dialog @@ -123,6 +127,10 @@ class PDFThumbnailViewer { #copiedThumbnails = null; + #savedThumbnails = null; + + #deletedPageNumbers = null; + #copiedPageNumbers = null; #boundPastePages = this.#pastePages.bind(this); @@ -139,6 +147,18 @@ class PDFThumbnailViewer { hasSelectedPages: false, }; + #statusLabel = null; + + #statusBar = null; + + #undoBar = null; + + #undoLabel = null; + + #undoButton = null; + + #undoCloseButton = null; + /** * @param {PDFThumbnailViewerOptions} options */ @@ -153,6 +173,8 @@ class PDFThumbnailViewer { abortSignal, enableHWA, enableSplitMerge, + statusBar, + undoBar, manageMenu, addFileButton, }) { @@ -166,6 +188,13 @@ class PDFThumbnailViewer { this.pageColors = pageColors || null; this.enableHWA = enableHWA || false; this.#enableSplitMerge = enableSplitMerge || false; + this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null; + this.#statusBar = statusBar?.viewsManagerStatusAction || null; + this.#undoBar = undoBar?.viewsManagerStatusUndo || null; + this.#undoLabel = undoBar?.viewsManagerStatusUndoLabel || null; + this.#undoButton = undoBar?.viewsManagerStatusUndoButton || null; + this.#undoCloseButton = undoBar?.viewsManagerStatusUndoCloseButton || null; + // TODO: uncomment when the "add file" feature is implemented. // this.#addFileButton = addFileButton; @@ -183,7 +212,7 @@ class PDFThumbnailViewer { this.#manageSaveAsButton = saveAs; saveAs.addEventListener("click", this.#saveExtractedPages.bind(this)); this.#manageDeleteButton = del; - del.addEventListener("click", this.#deletePages.bind(this)); + del.addEventListener("click", this.#deletePages.bind(this, "delete")); this.#manageCopyButton = copy; copy.addEventListener("click", this.#copyPages.bind(this)); this.#manageCutButton = cut; @@ -201,13 +230,19 @@ class PDFThumbnailViewer { this.#cutPages(); break; case "deletePage": - this.#deletePages(); + this.#deletePages("delete"); break; case "savePage": this.#saveExtractedPages(); break; } }); + + this.#undoButton?.addEventListener("click", this.#undo.bind(this)); + this.#undoCloseButton?.addEventListener( + "click", + this.#dismissUndo.bind(this) + ); } else { manageMenu.button.hidden = true; } @@ -492,17 +527,14 @@ class PDFThumbnailViewer { #updateThumbnails(currentPageNumber) { let newCurrentPageNumber = 0; const pagesMapper = this.#pagesMapper; - this.container.replaceChildren(); - const prevThumbnails = this._thumbnails; + const prevThumbnails = (this.#savedThumbnails = this._thumbnails); const newThumbnails = (this._thumbnails = []); const fragment = document.createDocumentFragment(); const isCut = this.#isCut; - const oldThumbnails = new Set(prevThumbnails); for (let i = 1, ii = pagesMapper.pagesNumber; i <= ii; i++) { const prevPageNumber = pagesMapper.getPrevPageNumber(i); if (prevPageNumber < 0) { let thumbnail = this.#copiedThumbnails.get(-prevPageNumber); - oldThumbnails.delete(thumbnail); thumbnail.checkbox.checked = false; if (isCut) { thumbnail.updateId(i); @@ -519,14 +551,10 @@ class PDFThumbnailViewer { const newThumbnail = prevThumbnails[prevPageNumber - 1]; newThumbnails.push(newThumbnail); newThumbnail.updateId(i); - oldThumbnails.delete(newThumbnail); newThumbnail.checkbox.checked = false; fragment.append(newThumbnail.div); } - this.container.append(fragment); - for (const oldThumbnail of oldThumbnails) { - oldThumbnail.destroy(); - } + this.container.replaceChildren(fragment); return newCurrentPageNumber; } @@ -676,6 +704,80 @@ class PDFThumbnailViewer { }, 0); } + #undo() { + if (this.#copiedThumbnails) { + // We undo a copy or a cut. + this.#copiedThumbnails = null; + this.#pagesMapper.cancelCopy(); + this.#clearSelection(); + this.#toggleMenuEntries(false); + this.#updateStatus("select"); + this.#togglePasteMode(false); + + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper: this.#pagesMapper, + type: "cancelCopy", + }); + } + + this.#isCut = false; + if (this.#savedThumbnails) { + const fragment = document.createDocumentFragment(); + for (let i = 1, ii = this.#savedThumbnails.length; i <= ii; i++) { + const thumbnail = this.#savedThumbnails[i - 1]; + thumbnail.updateId(i); + thumbnail.checkbox.checked = false; + fragment.append(thumbnail.div); + } + this.container.replaceChildren(fragment); + this._thumbnails = this.#savedThumbnails; + this.#savedThumbnails = null; + this.#pagesMapper.cancelDelete(); + + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper: this.#pagesMapper, + type: "cancelDelete", + }); + } + } + + #dismissUndo() { + this.#copiedThumbnails = null; + if (this.#deletedPageNumbers) { + for (const pageNumber of this.#deletedPageNumbers) { + this.#savedThumbnails[pageNumber - 1].destroy(); + } + this.#deletedPageNumbers = null; + this.#savedThumbnails = null; + } + this.#isCut = false; + this.#updateStatus("select"); + this.#togglePasteMode(false); + this.#pagesMapper.cleanSavedData(); + + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper: this.#pagesMapper, + type: "cleanSavedData", + }); + } + + #togglePasteMode(enable) { + if (enable) { + this.container.classList.add("pasteMode"); + for (const thumbnail of this._thumbnails) { + thumbnail.addPasteButton(this.#boundPastePages); + } + } else { + this.container.classList.remove("pasteMode"); + for (const thumbnail of this._thumbnails) { + thumbnail.removePasteButton(); + } + } + } + #saveExtractedPages() { this.eventBus.dispatch("saveextractedpages", { source: this, @@ -686,12 +788,13 @@ class PDFThumbnailViewer { } #copyPages(clearSelection = true) { + this.#updateStatus(this.#isCut ? "cut" : "copy"); const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from( this.#selectedPages ).sort((a, b) => a - b)); const pagesMapper = this.#pagesMapper; pagesMapper.copyPages(pageNumbersToCopy); - this.#copiedThumbnails ||= new Map(); + this.#copiedThumbnails = new Map(); for (const pageNumber of pageNumbersToCopy) { this.#copiedThumbnails.set(pageNumber, this._thumbnails[pageNumber - 1]); } @@ -704,10 +807,7 @@ class PDFThumbnailViewer { if (clearSelection) { this.#clearSelection(); } - for (const thumbnail of this._thumbnails) { - thumbnail.addPasteButton(this.#boundPastePages); - } - this.container.classList.add("pasteMode"); + this.#togglePasteMode(true); this.#toggleMenuEntries(false); } @@ -718,10 +818,7 @@ class PDFThumbnailViewer { } #pastePages(index) { - this.container.classList.remove("pasteMode"); - for (const thumbnail of this._thumbnails) { - thumbnail.removePasteButton(); - } + this.#togglePasteMode(false); this.#toggleMenuEntries(true); const pagesMapper = this.#pagesMapper; @@ -744,6 +841,7 @@ class PDFThumbnailViewer { this.#copiedThumbnails = null; this.#isCut = false; this.#updateMenuEntries(); + this.#updateStatus("select"); this.#updateCurrentPage(currentPageNumber); } @@ -753,11 +851,16 @@ class PDFThumbnailViewer { if (selectedPages.size === 0) { return; } + if (type === "delete") { + this.#updateStatus("delete"); + } const pagesMapper = this.#pagesMapper; let currentPageNumber = selectedPages.has(this._currentPageNumber) ? 0 : this._currentPageNumber; - const pagesToDelete = Uint32Array.from(selectedPages).sort((a, b) => a - b); + const pagesToDelete = (this.#deletedPageNumbers = Uint32Array.from( + selectedPages + ).sort((a, b) => a - b)); pagesMapper.deletePages(pagesToDelete); currentPageNumber = this.#updateThumbnails(currentPageNumber); @@ -793,6 +896,64 @@ class PDFThumbnailViewer { !enable; } + #updateStatus(type) { + if (!this.#statusBar || !this.#undoBar) { + return; + } + const count = this.#selectedPages?.size || 0; + if (type === "select") { + this.#statusLabel.setAttribute( + "data-l10n-id", + count + ? "pdfjs-views-manager-pages-status-action-label" + : "pdfjs-views-manager-pages-status-none-action-label" + ); + if (count) { + this.#statusLabel.setAttribute( + "data-l10n-args", + JSON.stringify({ count }) + ); + } else { + this.#statusLabel.removeAttribute("data-l10n-args"); + } + this.#statusBar.classList.toggle("hidden", false); + this.#undoBar.classList.toggle("hidden", true); + return; + } + + let l10nId; + switch (type) { + case "copy": + l10nId = "pdfjs-views-manager-pages-status-undo-copy-label"; + break; + case "cut": + l10nId = "pdfjs-views-manager-status-undo-cut-label"; + break; + case "delete": + l10nId = "pdfjs-views-manager-pages-status-undo-delete-label"; + break; + } + this.#undoLabel.setAttribute("data-l10n-id", l10nId); + this.#undoLabel.setAttribute("data-l10n-args", JSON.stringify({ count })); + + if (type === "copy") { + this.#undoButton.firstElementChild.setAttribute( + "data-l10n-id", + "pdfjs-views-manager-status-done-button-label" + ); + this.#undoCloseButton.classList.toggle("hidden", true); + } else { + this.#undoButton.firstElementChild.setAttribute( + "data-l10n-id", + "pdfjs-views-manager-status-undo-button-label" + ); + this.#undoCloseButton.classList.toggle("hidden", false); + } + + this.#statusBar.classList.toggle("hidden", true); + this.#undoBar.classList.toggle("hidden", false); + } + #moveDraggedContainer(dx, dy) { if (this.#isOneColumnView) { dx = 0; @@ -1047,6 +1208,7 @@ class PDFThumbnailViewer { set.delete(pageNumber); } this.#updateMenuEntries(); + this.#updateStatus("select"); } #addDragListeners() { diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 607ca2eaed6c6..19adeffe7bbdd 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -290,6 +290,10 @@ class PDFViewer { #copiedPageViews = null; + #savedPageViews = null; + + #deletedPageNumbers = null; + /** * @param {PDFViewerOptions} options */ @@ -1187,11 +1191,42 @@ class PDFViewer { return; } + if (type === "cancelCopy") { + this.#copiedPageViews = null; + return; + } + const isCut = type === "cut"; if (isCut || type === "delete") { - for (const pageNum of pageNumbers) { - this._pages[pageNum - 1].deleteMe(isCut); + this.#savedPageViews = this._pages; + this.#deletedPageNumbers = pageNumbers; + } + + if (type === "cancelDelete") { + const viewerElement = + this._scrollMode === ScrollMode.PAGE ? null : this.viewer; + if (viewerElement) { + const fragment = document.createDocumentFragment(); + for (let i = 0, ii = this.#savedPageViews.length; i < ii; i++) { + const page = this.#savedPageViews[i]; + page.updatePageNumber(i + 1); + fragment.append(page.div); + } + viewerElement.replaceChildren(fragment); + } + this._pages = this.#savedPageViews; + this.#savedPageViews = null; + this.#deletedPageNumbers = null; + return; + } + + if (type === "cleanSavedData") { + for (const pageNum of this.#deletedPageNumbers) { + this.#savedPageViews[pageNum - 1].deleteMe(); } + this.#savedPageViews = null; + this.#deletedPageNumbers = null; + return; } this._currentPageNumber = 0; @@ -1221,14 +1256,11 @@ class PDFViewer { const viewerElement = this._scrollMode === ScrollMode.PAGE ? null : this.viewer; if (viewerElement) { - viewerElement.replaceChildren(); const fragment = document.createDocumentFragment(); - for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) { - const { div } = newPages[i]; - div.setAttribute("data-page-number", i + 1); + for (const { div } of newPages) { fragment.append(div); } - viewerElement.append(fragment); + viewerElement.replaceChildren(fragment); } setTimeout(() => { diff --git a/web/viewer.html b/web/viewer.html index 15180941efb9e..8cd0d8f77ae7e 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -230,7 +230,7 @@