From dd6a0c6cf48b932243e932b28c3175f23363f81b Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 19 Jan 2026 20:46:43 +0100 Subject: [PATCH] Add a manage button in the thumbnail view in order to save an edited pdf (bug 2010830) --- src/display/display_utils.js | 31 ++++++++++- test/integration/reorganize_pages_spec.mjs | 62 ++++++++++++++++++++++ test/integration/thumbnail_view_spec.mjs | 7 +++ web/app.js | 35 ++++++++++++ web/pdf_thumbnail_viewer.js | 34 ++++++++++-- web/viewer.html | 10 ++-- web/viewer.js | 8 +++ 7 files changed, 177 insertions(+), 10 deletions(-) diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 1d9a1be78..e12c702a0 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -1182,10 +1182,39 @@ class PagesMapper { // Finally insert the moved pages. pageNumberToId.set(mappedPagesToMove, adjustedTarget); + let hasChanged = false; for (let i = 0, ii = pagesNumber; i < ii; i++) { - idToPageNumber[pageNumberToId[i] - 1] = i + 1; + const id = pageNumberToId[i]; + hasChanged ||= id !== i + 1; + idToPageNumber[id - 1] = i + 1; } this.#updateListeners(); + + if (!hasChanged) { + // Reset. + this.pagesNumber = 0; + } + } + + /** + * Checks if the page mappings have been altered from their initial state. + * @returns {boolean} True if the mappings have been altered, false otherwise. + */ + hasBeenAltered() { + return PagesMapper.#pageNumberToId !== null; + } + + /** + * Gets the current page mapping suitable for saving. + * @returns {Object} An object containing the page indices. + */ + getPageMappingForSaving() { + // Saving is index-based. + return { + pageIndices: PagesMapper.#idToPageNumber + ? PagesMapper.#idToPageNumber.map(x => x - 1) + : null, + }; } getPrevPageNumber(pageNumber) { diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 2f258e5e7..647bea1cf 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -568,4 +568,66 @@ describe("Reorganize Pages View", () => { ); }); }); + + describe("Save a pdf", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + "#viewsManagerToggleButton", + "1", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should check that a save is triggered", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + const rect1 = await getRect(page, getThumbnailSelector(1)); + const rect2 = await getRect(page, getThumbnailSelector(2)); + + await dragAndDrop( + page, + getThumbnailSelector(1), + [[0, rect2.y - rect1.y + rect2.height / 2]], + 10 + ); + + const handleSaveAs = await createPromise(page, resolve => { + window.PDFViewerApplication.eventBus.on( + "savepageseditedpdf", + ({ data }) => { + resolve(Array.from(data.pageIndices)); + }, + { + once: true, + } + ); + }); + + await page.click("#viewsManagerStatusActionButton"); + await page.waitForSelector("#viewsManagerStatusActionSaveAs", { + visible: true, + }); + await page.click("#viewsManagerStatusActionSaveAs"); + const pageIndices = await awaitPromise(handleSaveAs); + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual([ + 1, 0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + ]); + }) + ); + }); + }); }); diff --git a/test/integration/thumbnail_view_spec.mjs b/test/integration/thumbnail_view_spec.mjs index 918e2f7f5..8232ef20d 100644 --- a/test/integration/thumbnail_view_spec.mjs +++ b/test/integration/thumbnail_view_spec.mjs @@ -124,6 +124,13 @@ describe("PDF Thumbnail View", () => { .withContext(`In ${browserName}`) .toBe(true); + await kbFocusNext(page); + expect( + await isElementFocused(page, "#viewsManagerStatusActionButton") + ) + .withContext(`In ${browserName}`) + .toBe(true); + await kbFocusNext(page); expect( await isElementFocused( diff --git a/web/app.js b/web/app.js index 19d57bbd8..b482c84f0 100644 --- a/web/app.js +++ b/web/app.js @@ -605,6 +605,7 @@ const PDFViewerApplication = { abortSignal, enableHWA, enableSplitMerge: AppOptions.get("enableSplitMerge"), + manageMenu: appConfig.viewsManager.manageMenu, }); renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer); } @@ -2194,6 +2195,11 @@ const PDFViewerApplication = { this.onBeforePagesEdited.bind(this), opts ); + eventBus._on( + "savepageseditedpdf", + this.onSavePagesEditedPDF.bind(this), + opts + ); }, bindWindowEvents() { @@ -2376,6 +2382,35 @@ const PDFViewerApplication = { this.pdfViewer.onPagesEdited(data); }, + async onSavePagesEditedPDF({ + data: { includePages, excludePages, pageIndices }, + }) { + if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { + return; + } + if (!this.pdfDocument) { + return; + } + const pageInfo = { + document: null, // For now, no merge. + includePages, + excludePages, + pageIndices, + }; + const modifiedPdfBytes = await this.pdfDocument.extractPages([pageInfo]); + if (!modifiedPdfBytes) { + console.error( + "Something wrong happened when saving the edited PDF.\nPlease file a bug." + ); + return; + } + this.downloadManager.download( + modifiedPdfBytes, + this._downloadUrl, + this._docFilename + ); + }, + _accumulateTicks(ticks, prop) { // If the direction changed, reset the accumulated ticks. if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) { diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index afc30a36b..a11636c4c 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -28,6 +28,7 @@ import { watchScroll, } from "./ui_utils.js"; import { MathClamp, noContextMenu, PagesMapper, stopEvent } from "pdfjs-lib"; +import { Menu } from "./menu.js"; import { PDFThumbnailView } from "./pdf_thumbnail_view.js"; const SCROLL_OPTIONS = { @@ -67,6 +68,8 @@ 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} [manageMenu] - The menu elements to manage saving edited + * PDF. */ /** @@ -109,6 +112,8 @@ class PDFThumbnailViewer { #pagesMapper = PagesMapper.instance; + #manageSaveAsButton = null; + /** * @param {PDFThumbnailViewerOptions} options */ @@ -123,6 +128,7 @@ class PDFThumbnailViewer { abortSignal, enableHWA, enableSplitMerge, + manageMenu, }) { this.scrollableContainer = container.parentElement; this.container = container; @@ -135,6 +141,20 @@ class PDFThumbnailViewer { this.enableHWA = enableHWA || false; this.#enableSplitMerge = enableSplitMerge || false; + if (this.#enableSplitMerge && manageMenu) { + const { button, menu, copy, cut, delete: del, saveAs } = manageMenu; + this._manageMenu = new Menu(menu, button, [copy, cut, del, saveAs]); + this.#manageSaveAsButton = saveAs; + saveAs.addEventListener("click", () => { + this.eventBus.dispatch("savepageseditedpdf", { + source: this, + data: this.#pagesMapper.getPageMappingForSaving(), + }); + }); + } else { + manageMenu.button.hidden = true; + } + this.scroll = watchScroll( this.scrollableContainer, this.#scrollUpdated.bind(this), @@ -519,10 +539,16 @@ class PDFThumbnailViewer { selectedPages.clear(); this.#pageNumberToRemove = NaN; - this.eventBus.dispatch("pagesedited", { - source: this, - pagesMapper, - }); + const isIdentity = (this.#manageSaveAsButton.disabled = + !this.#pagesMapper.hasBeenAltered()); + if (!isIdentity) { + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper, + index: newIndex, + pagesToMove, + }); + } const newCurrentPageNumber = pagesMapper.getPageNumber(newCurrentPageId); setTimeout(() => { diff --git a/web/viewer.html b/web/viewer.html index 34c5e535e..3cd2ffd10 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -187,7 +187,7 @@ See https://github.com/adobe-type-tools/cmap-resources
-