From 8c9b819b4e3e485fbab8d4434b8d27f443db42e1 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Thu, 2 Apr 2026 19:34:04 +0200 Subject: [PATCH] Add the UI for merging PDFs (bug 2028071) --- l10n/en-US/viewer.ftl | 2 + src/core/editor/pdf_editor.js | 188 +++++++++++++++------ test/integration/reorganize_pages_spec.mjs | 67 ++++++++ test/pdfs/.gitignore | 1 + test/pdfs/three_pages_with_number.pdf | Bin 0 -> 25798 bytes test/unit/api_spec.js | 110 ++++++++++++ web/app.js | 25 ++- web/app_options.js | 5 + web/pdf_thumbnail_viewer.js | 146 ++++++++++++---- web/viewer.html | 1 + web/viewer.js | 14 +- web/views_manager.css | 20 ++- web/views_manager.js | 15 +- 13 files changed, 496 insertions(+), 98 deletions(-) create mode 100755 test/pdfs/three_pages_with_number.pdf diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index c281f1d10..3186b9a4d 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -783,3 +783,5 @@ pdfjs-views-manager-paste-button-after = # Badge used to promote a new feature in the UI, keep it as short as possible. # It's spelled uppercase for English, but it can be translated as usual. pdfjs-new-badge-content = NEW + +pdfjs-views-manager-waiting-for-file = Uploading file… diff --git a/src/core/editor/pdf_editor.js b/src/core/editor/pdf_editor.js index 166eadd8a..51f5b5faf 100644 --- a/src/core/editor/pdf_editor.js +++ b/src/core/editor/pdf_editor.js @@ -560,8 +560,138 @@ class PDFEditor { * excluded ranges (inclusive) or indices. * @property {Array} [pageIndices] * position of the pages in the final document. + * @property {number} [insertAfter] + * 0-based index in the base sequential document after which to insert the + * pages. Sequential pageInfos (those without pageIndices) have their indices + * shifted to accommodate the insertion. Cannot be combined with pageIndices. */ + /** + * Return the document-local page indices that pass the include/exclude + * filters for the given pageInfo, in document order. + * @param {PageInfo} pageInfo + * @returns {Array} + */ + #getFilteredPageIndices({ document, includePages, excludePages }) { + if (!document) { + return []; + } + let keptIndices, keptRanges, deletedIndices, deletedRanges; + for (const page of includePages || []) { + if (Array.isArray(page)) { + (keptRanges ||= []).push(page); + } else { + (keptIndices ||= new Set()).add(page); + } + } + for (const page of excludePages || []) { + if (Array.isArray(page)) { + (deletedRanges ||= []).push(page); + } else { + (deletedIndices ||= new Set()).add(page); + } + } + const indices = []; + for (let i = 0, ii = document.numPages; i < ii; i++) { + if (deletedIndices?.has(i)) { + continue; + } + if (deletedRanges) { + let isDeleted = false; + for (const [start, end] of deletedRanges) { + if (i >= start && i <= end) { + isDeleted = true; + break; + } + } + if (isDeleted) { + continue; + } + } + let takePage = false; + if (keptIndices) { + takePage = keptIndices.has(i); + } + if (!takePage && keptRanges) { + for (const [start, end] of keptRanges) { + if (i >= start && i <= end) { + takePage = true; + break; + } + } + } + if (!takePage && !keptIndices && !keptRanges) { + takePage = true; + } + if (takePage) { + indices.push(i); + } + } + return indices; + } + + /** + * Resolve insertAfter pageInfos by converting them (and sequential pageInfos) + * to explicit pageIndices, shifting indices to accommodate each insertion. + * insertAfter values are relative to the base sequential sequence (i.e. the + * concatenation of pages from pageInfos that have neither pageIndices nor + * insertAfter), so they are independent of each other. + * @param {Array} pageInfos + * @returns {Array} + */ + #resolveInsertAfterIndices(pageInfos) { + // Single pass: build the base sequential sequence and collect insertAfter + // entries, computing each pageInfo's filtered page count only once and only + // for pageInfos that actually contribute pages. + const sequence = []; // each element is the index into pageInfos + const insertAfterList = []; + for (let i = 0; i < pageInfos.length; i++) { + const info = pageInfos[i]; + if (!info.document || info.pageIndices) { + continue; + } + const count = this.#getFilteredPageIndices(info).length; + if (info.insertAfter === undefined) { + for (let j = 0; j < count; j++) { + sequence.push(i); + } + } else { + insertAfterList.push({ i, insertAfter: info.insertAfter, count }); + } + } + + // Sort by insertAfter value so that each value is interpreted relative to + // the same base sequential sequence, then insert into the sequence. + // The offset accumulates the number of pages already inserted, converting + // base-relative positions to current-sequence positions. + insertAfterList.sort((a, b) => a.insertAfter - b.insertAfter); + let offset = 0; + for (const { i, insertAfter, count } of insertAfterList) { + const insertPos = insertAfter + 1 + offset; + sequence.splice(insertPos, 0, ...new Array(count).fill(i)); + offset += count; + } + + // Map each pageInfo index to its final positions in the sequence using a + // plain array (keys are dense integers so no need for a Map). + const pageIndicesArr = new Array(pageInfos.length); + for (let pos = 0; pos < sequence.length; pos++) { + const infoIdx = sequence[pos]; + (pageIndicesArr[infoIdx] ||= []).push(pos); + } + + // Return updated pageInfos: sequential and insertAfter pageInfos now have + // explicit pageIndices; already-indexed pageInfos are left unchanged. + return pageInfos.map((info, i) => { + if (!info.document || info.pageIndices) { + return info; + } + const newInfo = { ...info, pageIndices: pageIndicesArr[i] || [] }; + delete newInfo.insertAfter; + return newInfo; + }); + } + /** * Extract pages from the given documents. * @param {Array} pageInfos @@ -574,6 +704,9 @@ class PDFEditor { * @return {Promise} */ async extractPages(pageInfos, annotationStorage, handler, task) { + if (pageInfos.some(info => info.insertAfter !== undefined)) { + pageInfos = this.#resolveInsertAfterIndices(pageInfos); + } const promises = []; let newIndex = 0; this.isSingleFile = @@ -610,57 +743,12 @@ class PDFEditor { const documentData = new DocumentData(document); allDocumentData.push(documentData); promises.push(this.#collectDocumentData(documentData)); - let keptIndices, keptRanges, deletedIndices, deletedRanges; - for (const page of includePages || []) { - if (Array.isArray(page)) { - (keptRanges ||= []).push(page); - } else { - (keptIndices ||= new Set()).add(page); - } - } - for (const page of excludePages || []) { - if (Array.isArray(page)) { - (deletedRanges ||= []).push(page); - } else { - (deletedIndices ||= new Set()).add(page); - } - } let pageIndex = 0; - for (let i = 0, ii = document.numPages; i < ii; i++) { - if (deletedIndices?.has(i)) { - continue; - } - if (deletedRanges) { - let isDeleted = false; - for (const [start, end] of deletedRanges) { - if (i >= start && i <= end) { - isDeleted = true; - break; - } - } - if (isDeleted) { - continue; - } - } - - let takePage = false; - if (keptIndices) { - takePage = keptIndices.has(i); - } - if (!takePage && keptRanges) { - for (const [start, end] of keptRanges) { - if (i >= start && i <= end) { - takePage = true; - break; - } - } - } - if (!takePage && !keptIndices && !keptRanges) { - takePage = true; - } - if (!takePage) { - continue; - } + for (const i of this.#getFilteredPageIndices({ + document, + includePages, + excludePages, + })) { let newPageIndex; if (pageIndices) { newPageIndex = pageIndices[pageIndex++]; diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 2e0cb6de6..0310cc3dd 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -40,6 +40,9 @@ import { waitForTextToBe, waitForTooltipToBe, } from "./test_utils.mjs"; +import path from "path"; + +const __dirname = import.meta.dirname; async function waitForThumbnailVisible(page, pageNums) { await showViewsManager(page); @@ -3030,4 +3033,68 @@ describe("Reorganize Pages View", () => { ); }); }); + + describe("Merge PDF", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "three_pages_with_number.pdf", + "#viewsManagerToggleButton", + "1", + null, + { enableSplitMerge: true, enableMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should merge a PDF after the current page", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + + // Navigate to page 2 so the merged PDF is inserted after it. + await page.evaluate(() => { + window.PDFViewerApplication.page = 2; + }); + await page.waitForFunction( + () => window.PDFViewerApplication.page === 2 + ); + await waitAndClick(page, getThumbnailSelector(2)); + + const handleMerged = await createPromise(page, resolve => { + window.PDFViewerApplication.eventBus._on( + "thumbnailsloaded", + resolve, + { once: true } + ); + }); + + const picker = await page.$("#viewsManagerAddFilePicker"); + await picker.uploadFile( + path.join(__dirname, "../pdfs/three_pages_with_number.pdf") + ); + await awaitPromise(handleMerged); + + // Original 3 pages + 3 merged pages = 6 pages total. + await page.waitForFunction( + () => parseInt(document.getElementById("pageNumber").max, 10) === 6 + ); + + // Pages 1–2 come from the original document, then all 3 pages of + // the merged PDF, then pages 4–6 of the original shifted to the end. + await waitForHavingContents(page, [1, 2, 1, 2, 3, 3]); + + await waitForTextToBe( + page, + "#viewsManagerStatusActionLabel", + `${FSI}3${PDI} selected` + ); + }) + ); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 7ba0239d7..cbd9f5330 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -901,3 +901,4 @@ !issue21068.pdf !recursiveCompositGlyf.pdf !issue19634.pdf +!three_pages_with_number.pdf diff --git a/test/pdfs/three_pages_with_number.pdf b/test/pdfs/three_pages_with_number.pdf new file mode 100755 index 0000000000000000000000000000000000000000..7df48a980dc7f4955be163223e8dcf4f2ab3c4e6 GIT binary patch literal 25798 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 literal 0 HcmV?d00001 diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 0795f9f34..79952547c 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -7018,6 +7018,116 @@ small scripts as well as for`); expect(newPdfDoc.numPages).toEqual(2); await passwordAcceptedLoadingTask.destroy(); }); + + it("insertAfter places pages at the given position", async function () { + // page_with_number.pdf has 17 pages; text on page N (1-based) is "N". + // Sequential pageInfo contributes pages 1 and 3 (0-based) at base + // positions 0 and 1. The insertAfter pageInfo inserts page 2 after + // base position 0, so the final order should be: "1" · "2" · "3". + let loadingTask = getDocument( + buildGetDocumentParams("page_with_number.pdf") + ); + let pdfDoc = await loadingTask.promise; + const data = await pdfDoc.extractPages([ + { document: null, includePages: [0, 2] }, + { document: null, includePages: [1], insertAfter: 0 }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + expect(pdfDoc.numPages).toEqual(3); + + for (const [pageNum, expected] of [ + [1, "1"], + [2, "2"], + [3, "3"], + ]) { + const pdfPage = await pdfDoc.getPage(pageNum); + const { items } = await pdfPage.getTextContent(); + expect(mergeText(items)) + .withContext(`Page ${pageNum}`) + .toEqual(expected); + } + + await loadingTask.destroy(); + }); + + it("insertAfter shifts sequential pageInfos across multiple entries", async function () { + // Two separate sequential pageInfos (pages 1 and 3, 0-based) form + // the base sequence at positions 0 and 1. Page 2 is inserted after + // base position 0, so both sequential entries should be shifted and + // the final order should be: "1" · "2" · "3". + let loadingTask = getDocument( + buildGetDocumentParams("page_with_number.pdf") + ); + let pdfDoc = await loadingTask.promise; + const data = await pdfDoc.extractPages([ + { document: null, includePages: [0] }, + { document: null, includePages: [2] }, + { document: null, includePages: [1], insertAfter: 0 }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + expect(pdfDoc.numPages).toEqual(3); + + for (const [pageNum, expected] of [ + [1, "1"], + [2, "2"], + [3, "3"], + ]) { + const pdfPage = await pdfDoc.getPage(pageNum); + const { items } = await pdfPage.getTextContent(); + expect(mergeText(items)) + .withContext(`Page ${pageNum}`) + .toEqual(expected); + } + + await loadingTask.destroy(); + }); + + it("insertAfter without includePages inserts all pages", async function () { + // Sequential pageInfo uses pages 0–5 ("1"–"6", base positions 0–5). + // The insertAfter pageInfo has no includePages so all 17 pages are + // inserted after base position 4, landing between "5" and "6". + // Final order: "1"·"2"·"3"·"4"·"5" · "1"…"17" · "6" = 23 pages. + let loadingTask = getDocument( + buildGetDocumentParams("page_with_number.pdf") + ); + let pdfDoc = await loadingTask.promise; + const data = await pdfDoc.extractPages([ + { document: null, includePages: [0, 1, 2, 3, 4, 5] }, + { document: null, insertAfter: 4 }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + expect(pdfDoc.numPages).toEqual(23); + + // Last page of the first sequential chunk. + let pdfPage = await pdfDoc.getPage(5); + let { items } = await pdfPage.getTextContent(); + expect(mergeText(items)).withContext("Page 5").toEqual("5"); + + // First and last of the 17 inserted pages. + pdfPage = await pdfDoc.getPage(6); + ({ items } = await pdfPage.getTextContent()); + expect(mergeText(items)).withContext("Page 6").toEqual("1"); + + pdfPage = await pdfDoc.getPage(22); + ({ items } = await pdfPage.getTextContent()); + expect(mergeText(items)).withContext("Page 22").toEqual("17"); + + // Sequential page "6" shifted to the end. + pdfPage = await pdfDoc.getPage(23); + ({ items } = await pdfPage.getTextContent()); + expect(mergeText(items)).withContext("Page 23").toEqual("6"); + + await loadingTask.destroy(); + }); }); }); }); diff --git a/web/app.js b/web/app.js index ed78bd41e..859e22da1 100644 --- a/web/app.js +++ b/web/app.js @@ -375,6 +375,7 @@ const PDFViewerApplication = { enableGuessAltText: x => x === "true", enableNewBadge: x => x === "true", enablePermissions: x => x === "true", + enableMerge: x => x === "true", enableSplitMerge: x => x === "true", enableUpdatedAddImage: x => x === "true", highlightEditorColors: x => x, @@ -461,6 +462,7 @@ const PDFViewerApplication = { foreground: AppOptions.get("pageColorsForeground"), } : null; + const enableMerge = AppOptions.get("enableMerge"); const enableSplitMerge = AppOptions.get("enableSplitMerge"); let altTextManager; @@ -601,11 +603,13 @@ const PDFViewerApplication = { pageColors, abortSignal, enableSplitMerge, + enableMerge, enableNewBadge: AppOptions.get("enableNewBadge"), statusBar: viewsManager.viewsManagerStatusBar, undoBar: viewsManager.viewsManagerUndoBar, manageMenu: viewsManager.manageMenu, - addFileButton: viewsManager.viewsManagerAddFileButton, + waitingBar: viewsManager.viewsManagerWaitingBar, + addFileComponent: viewsManager.viewsManagerAddFile, }); renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer); } @@ -765,6 +769,7 @@ const PDFViewerApplication = { elements: appConfig.viewsManager, eventBus, l10n, + enableMerge, enableSplitMerge, globalAbortSignal: abortSignal, }); @@ -2217,6 +2222,7 @@ const PDFViewerApplication = { } eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts); eventBus._on("saveextractedpages", this.onSavePages.bind(this), opts); + eventBus._on("saveandload", this.onSaveAndLoad.bind(this), opts); }, bindWindowEvents() { @@ -2419,6 +2425,23 @@ const PDFViewerApplication = { ); }, + async onSaveAndLoad({ data: extractParams }) { + if (!this.pdfDocument) { + return; + } + const modifiedPdfBytes = await this.pdfDocument.extractPages(extractParams); + if (!modifiedPdfBytes) { + console.error( + "Something wrong happened when saving the edited PDF.\nPlease file a bug." + ); + return; + } + this.open({ + data: modifiedPdfBytes, + filename: 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/app_options.js b/web/app_options.js index d2154f1d7..d84ba9336 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -232,6 +232,11 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableMerge: { + /** @type {boolean} */ + value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableNewAltTextWhenAddingImage: { /** @type {boolean} */ value: true, diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 46c146999..dcd2e4128 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -66,6 +66,8 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15; * events. * @property {boolean} [enableNewBadge] - Enables the "new" badge for the split * and merge features. + * @property {boolean} [enableMerge] - Enables the merge feature. + * 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 @@ -74,8 +76,11 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15; * action. * @property {Object} [manageMenu] - The menu elements to manage saving edited * PDF. - * @property {HTMLButtonElement} addFileButton - The button that opens a dialog - * to add a PDF file to merge with the current one. + * @property {Object} [waitingBar] - The waiting bar elements shown during + * long-running operations. + * @property {Object} [addFileComponent] - The file picker and button used to + * add a PDF file to merge with the current one. + */ /** * Viewer control to display thumbnails for pages in a PDF document. @@ -83,6 +88,8 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15; class PDFThumbnailViewer { static #draggingScaleFactor = 0; + #enableMerge = false; + #enableSplitMerge = false; #dragAC = null; @@ -157,6 +164,8 @@ class PDFThumbnailViewer { #undoCloseButton = null; + #waitingBar = null; + #isInPasteMode = false; #hasUndoBarVisible = false; @@ -175,12 +184,14 @@ class PDFThumbnailViewer { maxCanvasDim, pageColors, abortSignal, + enableMerge, enableSplitMerge, enableNewBadge, statusBar, undoBar, + waitingBar, manageMenu, - addFileButton, + addFileComponent, }) { this.scrollableContainer = container.parentElement; this.container = container; @@ -190,6 +201,7 @@ class PDFThumbnailViewer { this.maxCanvasPixels = maxCanvasPixels; this.maxCanvasDim = maxCanvasDim; this.pageColors = pageColors || null; + this.#enableMerge = enableMerge || false; this.#enableSplitMerge = enableSplitMerge || false; this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null; this.#deselectButton = @@ -199,13 +211,11 @@ class PDFThumbnailViewer { 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; + this.#waitingBar = waitingBar || null; if (this.#enableSplitMerge && manageMenu) { const { - button, + button: menuButton, menu, copy, cut, @@ -217,19 +227,19 @@ class PDFThumbnailViewer { const newSpan = document.createElement("span"); newSpan.setAttribute("data-l10n-id", "pdfjs-new-badge-content"); newSpan.classList.add("newBadge"); - button.parentElement.before(newSpan); + menuButton.parentElement.before(newSpan); this.#newBadge = newSpan; } this.eventBus.on( "pagesloaded", () => { - button.disabled = false; + menuButton.disabled = false; }, { once: true } ); - this._manageMenu = new Menu(menu, button, [ + this._manageMenu = new Menu(menu, menuButton, [ copy, cut, del, @@ -248,7 +258,7 @@ class PDFThumbnailViewer { cut.addEventListener("click", this.#cutPages.bind(this)); this.#toggleMenuEntries(false); - button.disabled = true; + menuButton.disabled = true; this.eventBus.on("editingaction", ({ name }) => { switch (name) { @@ -301,6 +311,63 @@ class PDFThumbnailViewer { this.#updateStatus("select"); }); this.#deselectButton.classList.toggle("hidden", true); + + if (this.#enableMerge && addFileComponent) { + const { picker, button } = addFileComponent; + picker.addEventListener("change", async () => { + const file = picker.files?.[0]; + if (!file) { + return; + } + if (file.type !== "application/pdf") { + const magic = await file.slice(0, 5).text(); + if (magic !== "%PDF-") { + return; + } + } + this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file"); + const currentPageIndex = this._currentPageNumber - 1; + const buffer = await file.bytes(); + const pagesCount = this.#pagesMapper.pagesNumber; + const data = this.hasStructuralChanges() + ? this.getStructuralChanges() + : [{ document: null }]; + data.push({ + document: buffer, + insertAfter: currentPageIndex ?? -1, + }); + this.eventBus._on( + "thumbnailsloaded", + () => { + this.#toggleBar("status"); + const newPagesCount = this.#pagesMapper.pagesNumber; + const insertedPagesCount = newPagesCount - pagesCount; + for ( + let i = currentPageIndex + 1, + ii = currentPageIndex + 1 + insertedPagesCount; + i < ii; + i++ + ) { + this._thumbnails[i].checkbox.checked = true; + this.#selectPage(i + 1, true); + } + }, + { once: true } + ); + this.#reportTelemetry({ action: "merge" }); + this.eventBus.dispatch("saveandload", { + source: this, + data, + }); + }); + button.addEventListener("click", () => { + picker.click(); + }); + this.#waitingBar.closeButton?.addEventListener("click", () => { + this.#toggleBar("status"); + picker.value = ""; + }); + } } else { manageMenu.button.hidden = true; } @@ -466,6 +533,9 @@ class PDFThumbnailViewer { const thumbnailView = this._thumbnails[this._currentPageNumber - 1]; thumbnailView.toggleCurrent(/* isCurrent = */ true); this.container.append(fragment); + this.eventBus.dispatch("thumbnailsloaded", { + source: this, + }); }) .catch(reason => { console.error("Unable to initialize thumbnail viewer", reason); @@ -830,6 +900,37 @@ class PDFThumbnailViewer { return size > 0 && size < this._thumbnails.length; } + #toggleBar(type, message, args) { + this.#statusBar.classList.toggle("hidden", type !== "status"); + this.#waitingBar.container.classList.toggle("hidden", type !== "waiting"); + this.#undoBar.classList.toggle("hidden", type !== "undo"); + this.#hasUndoBarVisible = type === "undo"; + + switch (type) { + case "waiting": + this.#waitingBar.label.setAttribute("data-l10n-id", message); + break; + case "undo": + this.#undoLabel.setAttribute("data-l10n-id", message); + if (args) { + this.#undoLabel.setAttribute("data-l10n-args", JSON.stringify(args)); + } + break; + case "status": + if (args) { + this.#statusLabel.setAttribute( + "data-l10n-args", + JSON.stringify(args) + ); + } else { + this.#statusLabel.removeAttribute("data-l10n-args"); + } + this.#newBadge?.classList.toggle("hidden", !!args); + this.#deselectButton.classList.toggle("hidden", !args); + break; + } + } + #togglePasteMode(enable) { this.#isInPasteMode = enable; if (enable) { @@ -996,21 +1097,7 @@ class PDFThumbnailViewer { ? "pdfjs-views-manager-pages-status-action-label" : "pdfjs-views-manager-pages-status-none-action-label" ); - if (count) { - this.#newBadge?.classList.add("hidden"); - this.#statusLabel.setAttribute( - "data-l10n-args", - JSON.stringify({ count }) - ); - this.#deselectButton.classList.toggle("hidden", false); - } else { - this.#newBadge?.classList.remove("hidden"); - this.#statusLabel.removeAttribute("data-l10n-args"); - this.#deselectButton.classList.toggle("hidden", true); - } - this.#statusBar.classList.toggle("hidden", false); - this.#undoBar.classList.toggle("hidden", true); - this.#hasUndoBarVisible = false; + this.#toggleBar("status", "", count ? { count } : null); return; } @@ -1026,8 +1113,7 @@ class PDFThumbnailViewer { 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 })); + this.#toggleBar("undo", l10nId, { count }); if (type === "copy") { this.#undoButton.firstElementChild.setAttribute( @@ -1042,10 +1128,6 @@ class PDFThumbnailViewer { ); this.#undoCloseButton.classList.toggle("hidden", false); } - - this.#statusBar.classList.toggle("hidden", true); - this.#undoBar.classList.toggle("hidden", false); - this.#hasUndoBarVisible = true; } #moveDraggedContainer(dx, dy) { diff --git a/web/viewer.html b/web/viewer.html index 502bb5f6d..0d5d90c7d 100644 --- a/web/viewer.html +++ b/web/viewer.html @@ -162,6 +162,7 @@ See https://github.com/adobe-type-tools/cmap-resources hidden="true" > +