From d755fba96a4bbf55e3b580ac41e6e57b93fcda92 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 16 Feb 2026 20:28:52 +0100 Subject: [PATCH] Add support for deleting, cutting, copying and pasting pages (bug 2010830, 2010831) --- l10n/en-US/viewer.ftl | 1 + src/display/api.js | 55 +++- src/display/display_utils.js | 280 +++++++++++++++--- src/display/editor/annotation_editor_layer.js | 37 +++ src/display/editor/tools.js | 31 +- test/integration/reorganize_pages_spec.mjs | 205 ++++++++++++- test/integration/test_utils.mjs | 12 +- test/integration/thumbnail_view_spec.mjs | 24 +- web/annotation_editor_layer_builder.js | 20 +- web/app.js | 21 +- web/base_pdf_page_view.js | 8 +- web/pdf_find_controller.js | 29 +- web/pdf_link_service.js | 2 +- web/pdf_page_view.js | 49 ++- web/pdf_thumbnail_view.js | 113 +++++-- web/pdf_thumbnail_viewer.js | 276 +++++++++++++---- web/pdf_viewer.js | 48 ++- web/views_manager.css | 58 +++- 18 files changed, 1041 insertions(+), 228 deletions(-) diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 9f2e43397..6ac456c58 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -766,3 +766,4 @@ pdfjs-views-manager-status-undo-button-label = Undo pdfjs-views-manager-status-close-button = .title = Close pdfjs-views-manager-status-close-button-label = Close +pdfjs-views-manager-paste-button-label = Paste diff --git a/src/display/api.js b/src/display/api.js index 383c709a2..e89807ac0 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2405,6 +2405,8 @@ class WorkerTransport { #passwordCapability = null; + #copiedPageInfo = null; + constructor( messageHandler, loadingTask, @@ -2464,11 +2466,42 @@ class WorkerTransport { } } - #updateCaches() { + #updateCaches({ type, pageNumbers }) { + if (type === "copy") { + this.#copiedPageInfo = new Map(); + for (const pageNum of pageNumbers) { + this.#copiedPageInfo.set(pageNum, { + proxy: this.#pageCache.get(pageNum - 1) || null, + promise: this.#pagePromises.get(pageNum - 1) || null, + }); + } + return; + } + + if (type === "delete") { + for (const pageNum of pageNumbers) { + this.#pageCache.delete(pageNum - 1); + this.#pagePromises.delete(pageNum - 1); + } + } + const newPageCache = new Map(); const newPromiseCache = new Map(); - for (let i = 0, ii = this.pagesMapper.pagesNumber; i < ii; i++) { - const prevPageIndex = this.pagesMapper.getPrevPageNumber(i + 1) - 1; + const { pagesMapper } = this; + for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) { + const prevPageNumber = pagesMapper.getPrevPageNumber(i + 1); + if (prevPageNumber < 0) { + const { proxy, promise } = + this.#copiedPageInfo?.get(-prevPageNumber) || {}; + if (proxy) { + newPageCache.set(i, proxy); + } + if (promise) { + newPromiseCache.set(i, promise); + } + continue; + } + const prevPageIndex = prevPageNumber - 1; const page = this.#pageCache.get(prevPageIndex); if (page) { newPageCache.set(i, page); @@ -3001,7 +3034,11 @@ class WorkerTransport { num: ref.num, gen: ref.gen, }); - return this.pagesMapper.getPageNumber(index + 1) - 1; + const pageNumber = this.pagesMapper.getPageNumber(index + 1); + if (pageNumber === 0) { + throw new Error("GetPageIndex: page has been removed."); + } + return pageNumber - 1; } getAnnotations(pageIndex, intent) { @@ -3150,9 +3187,13 @@ class WorkerTransport { } const refStr = ref.gen === 0 ? `${ref.num}R` : `${ref.num}R${ref.gen}`; const pageIndex = this.#pageRefCache.get(refStr); - return pageIndex >= 0 - ? this.pagesMapper.getPageNumber(pageIndex + 1) - : null; + if (pageIndex >= 0) { + const pageNumber = this.pagesMapper.getPageNumber(pageIndex + 1); + if (pageNumber !== 0) { + return pageNumber; + } + } + return null; } } diff --git a/src/display/display_utils.js b/src/display/display_utils.js index b3b5752d8..a927ac65c 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -1046,7 +1046,7 @@ function makePathFromDrawOPS(data) { class PagesMapper { /** * Maps page IDs to their corresponding page numbers. - * @type {Uint32Array|null} + * @type {Map>|null} */ #idToPageNumber = null; @@ -1058,9 +1058,9 @@ class PagesMapper { /** * Previous mapping of page IDs to page numbers. - * @type {Uint32Array|null} + * @type {Int32Array|null} */ - #prevIdToPageNumber = null; + #prevPageNumbers = null; /** * The total number of pages. @@ -1074,6 +1074,19 @@ class PagesMapper { */ #listeners = []; + /** + * Maps page numbers to their corresponding page IDs (used in copy + * operations). + * @type {Uint32Array|null} + */ + #copiedPageIds = null; + + /** + * Maps page IDs to their corresponding page numbers, used in copy operations. + * @type {Uint32Array|null} + */ + #copiedPageNumbers = null; + /** * Gets the total number of pages. * @returns {number} The number of pages. @@ -1092,16 +1105,33 @@ class PagesMapper { return; } this.#pagesNumber = n; - if (n === 0) { - this.#pageNumberToId = null; - this.#idToPageNumber = null; - } + this.#reset(); } + /** + * Resets the page mappings to their default state, where page IDs equal page + * numbers (1-indexed). This is called when the number of pages changes, or + * when the current mapping matches the default mapping after a move + * operation. + */ + #reset() { + this.#pageNumberToId = null; + this.#idToPageNumber = null; + } + + /** + * Adds a listener function that will be called whenever the page mappings + * are updated. + * @param {function} listener + */ addListener(listener) { this.#listeners.push(listener); } + /** + * Removes a previously added listener function. + * @param {function} listener + */ removeListener(listener) { const index = this.#listeners.indexOf(listener); if (index >= 0) { @@ -1109,28 +1139,56 @@ class PagesMapper { } } - #updateListeners() { + /** + * Calls all registered listener functions to notify them of changes to the + * page mappings. + * @param {Object} data - An object containing information about the update. + */ + #updateListeners(data) { for (const listener of this.#listeners) { - listener(); + listener(data); } } + /** + * Initializes the page mappings if they haven't been initialized yet. + * @param {boolean} mustInit + */ #init(mustInit) { if (this.#pageNumberToId) { return; } const n = this.#pagesNumber; - // Allocate a single array for better memory locality. - const array = new Uint32Array(3 * n); - const pageNumberToId = (this.#pageNumberToId = array.subarray(0, n)); - const idToPageNumber = (this.#idToPageNumber = array.subarray(n, 2 * n)); + const pageNumberToId = (this.#pageNumberToId = new Uint32Array(n)); + this.#prevPageNumbers = new Int32Array(pageNumberToId); + const idToPageNumber = (this.#idToPageNumber = new Map()); if (mustInit) { - for (let i = 0; i < n; i++) { - pageNumberToId[i] = idToPageNumber[i] = i + 1; + for (let i = 1; i <= n; i++) { + pageNumberToId[i - 1] = i; + idToPageNumber.set(i, [i]); + } + } + } + + /** + * Updates the mapping from page IDs to page numbers based on the current + * mapping from page numbers to page IDs. This should be called after any + * changes to the page-number-to-ID mapping to keep the two mappings in sync. + */ + #updateIdToPageNumber() { + const idToPageNumber = this.#idToPageNumber; + const pageNumberToId = this.#pageNumberToId; + idToPageNumber.clear(); + for (let i = 0, ii = this.#pagesNumber; i < ii; i++) { + const id = pageNumberToId[i]; + const pageNumbers = idToPageNumber.get(id); + if (pageNumbers) { + pageNumbers.push(i + 1); + } else { + idToPageNumber.set(id, [i + 1]); } } - this.#prevIdToPageNumber = array.subarray(2 * n); } /** @@ -1145,7 +1203,6 @@ class PagesMapper { this.#init(true); const pageNumberToId = this.#pageNumberToId; const idToPageNumber = this.#idToPageNumber; - this.#prevIdToPageNumber.set(idToPageNumber); const movedCount = pagesToMove.length; const mappedPagesToMove = new Uint32Array(movedCount); let removedBeforeTarget = 0; @@ -1182,17 +1239,118 @@ class PagesMapper { // Finally insert the moved pages. pageNumberToId.set(mappedPagesToMove, adjustedTarget); - let hasChanged = false; - for (let i = 0, ii = pagesNumber; i < ii; i++) { - const id = pageNumberToId[i]; - hasChanged ||= id !== i + 1; - idToPageNumber[id - 1] = i + 1; - } - this.#updateListeners(); + this.#setPrevPageNumbers(idToPageNumber, null); + this.#updateIdToPageNumber(); + this.#updateListeners({ type: "move" }); - if (!hasChanged) { - // Reset. - this.pagesNumber = 0; + if (pageNumberToId.every((id, i) => id === i + 1)) { + this.#reset(); + } + } + + /** + * Deletes a set of pages while keeping ID→number mappings in sync. + * @param {Array} pagesToDelete - Page numbers to delete (1-indexed). + * These must be unique and sorted in ascending order. + */ + deletePages(pagesToDelete) { + this.#init(true); + const pageNumberToId = this.#pageNumberToId; + const prevIdToPageNumber = this.#idToPageNumber; + + this.pagesNumber -= pagesToDelete.length; + this.#init(false); + const newPageNumberToId = this.#pageNumberToId; + + let sourceIndex = 0; + let destIndex = 0; + for (const pageNumber of pagesToDelete) { + const pageIndex = pageNumber - 1; + if (pageIndex !== sourceIndex) { + newPageNumberToId.set( + pageNumberToId.subarray(sourceIndex, pageIndex), + destIndex + ); + destIndex += pageIndex - sourceIndex; + } + sourceIndex = pageIndex + 1; + } + if (sourceIndex < pageNumberToId.length) { + newPageNumberToId.set(pageNumberToId.subarray(sourceIndex), destIndex); + } + + this.#setPrevPageNumbers(prevIdToPageNumber, null); + this.#updateIdToPageNumber(); + this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete }); + } + + /** + * Copies a set of pages while keeping ID→number mappings in sync. + * @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed). + */ + copyPages(pagesToCopy) { + this.#init(true); + this.#copiedPageNumbers = pagesToCopy; + this.#copiedPageIds = pagesToCopy.map( + pageNumber => this.#pageNumberToId[pageNumber - 1] + ); + this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy }); + } + + /** + * Pastes a set of pages while keeping ID→number mappings in sync. + * @param {number} index - Zero-based insertion index in the page-number list. + */ + pastePages(index) { + this.#init(true); + const pageNumberToId = this.#pageNumberToId; + const prevIdToPageNumber = this.#idToPageNumber; + const copiedPageNumbers = this.#copiedPageNumbers; + + const copiedPageMapping = new Map(); + let base = index; + for (const pageNumber of copiedPageNumbers) { + copiedPageMapping.set(++base, pageNumber); + } + this.pagesNumber += copiedPageNumbers.length; + this.#init(false); + const newPageNumberToId = this.#pageNumberToId; + + newPageNumberToId.set(pageNumberToId.subarray(0, index), 0); + newPageNumberToId.set(this.#copiedPageIds, index); + newPageNumberToId.set( + pageNumberToId.subarray(index), + index + copiedPageNumbers.length + ); + + this.#setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping); + this.#updateIdToPageNumber(); + this.#updateListeners({ type: "paste" }); + + this.#copiedPageIds = null; + } + + /** + * Updates the previous page numbers based on the current page-number-to-ID + * mapping and the provided previous ID-to-page-number mapping. + * This is used to keep track of the original page numbers for each page ID. + * @param {Map} prevIdToPageNumber + * @param {Map|null} copiedPageMapping + */ + #setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping) { + const prevPageNumbers = this.#prevPageNumbers; + const newPageNumberToId = this.#pageNumberToId; + const idsIndices = new Map(); + for (let i = 0, ii = this.#pagesNumber; i < ii; i++) { + const oldPageNumber = copiedPageMapping?.get(i + 1); + if (oldPageNumber) { + prevPageNumbers[i] = -oldPageNumber; + continue; + } + const id = newPageNumberToId[i]; + const j = idsIndices.get(id) || 0; + prevPageNumbers[i] = prevIdToPageNumber.get(id)?.[j]; + idsIndices.set(id, j + 1); } } @@ -1209,25 +1367,75 @@ class PagesMapper { * @returns {Object} An object containing the page indices. */ getPageMappingForSaving() { - // Saving is index-based. - return { - pageIndices: this.#idToPageNumber - ? this.#idToPageNumber.map(x => x - 1) - : null, - }; + const idToPageNumber = this.#idToPageNumber; + + // idToPageNumber maps used 1-based IDs to 1-based page numbers. + // For example if the final pdf contains page 3 twice and they are moved at + // page 1 and 4, then it contains: + // pageNumberToId = [3, ., ., 3, ...,] + // idToPageNumber = {3: [1, 4], ...} + // In such a case we need to take a page 3 from the original pdf and take + // page 3 from a "copy". + // So we need to pass to the api something like: + // [ { + // document: null // this pdf + // includePages: [ 2, ... ], // page 3 is at index 2 + // pageIndices: [0, ...], // page 3 will be at index 0 in the new pdf + // }, { + // document: null // this pdf + // includePages: [ 2, ... ], // page 3 is at index 2 + // pageIndices: [3, ...], // page 3 will be at index 3 in the new pdf + // } + // ] + + let nCopy = 0; + for (const pageNumbers of idToPageNumber.values()) { + nCopy = Math.max(nCopy, pageNumbers.length); + } + + const extractParams = new Array(nCopy); + for (let i = 0; i < nCopy; i++) { + extractParams[i] = { + document: null, + pageIndices: [], + includePages: [], + }; + } + + for (const [id, pageNumbers] of idToPageNumber) { + for (let i = 0, ii = pageNumbers.length; i < ii; i++) { + extractParams[i].includePages.push([id - 1, pageNumbers[i] - 1]); + } + } + + for (const { includePages, pageIndices } of extractParams) { + includePages.sort((a, b) => a[0] - b[0]); + for (let i = 0, ii = includePages.length; i < ii; i++) { + pageIndices.push(includePages[i][1]); + includePages[i] = includePages[i][0]; + } + } + + return extractParams; } + /** + * Gets the previous page number for a given page number. + * @param {number} pageNumber + * @returns {number} The previous page number for the given page number, or 0 + * if no mapping exists. + */ getPrevPageNumber(pageNumber) { - return this.#prevIdToPageNumber[this.#pageNumberToId[pageNumber - 1] - 1]; + return this.#prevPageNumbers[pageNumber - 1] ?? 0; } /** * Gets the page number for a given page ID. * @param {number} id - The page ID (1-indexed). - * @returns {number} The page number, or the ID itself if no mapping exists. + * @returns {number} The page number, or 0 if no mapping exists. */ getPageNumber(id) { - return this.#idToPageNumber?.[id - 1] ?? id; + return this.#idToPageNumber ? (this.#idToPageNumber.get(id)?.[0] ?? 0) : id; } /** diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 9d27cfca8..2af280b5c 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -145,7 +145,44 @@ class AnnotationEditorLayer { } updatePageIndex(newPageIndex) { + for (const editor of this.#allEditorsIterator) { + editor.updatePageIndex(newPageIndex); + } + this.pageIndex = newPageIndex; + this.#uiManager.addLayer(this); + } + + /** + * Clones all annotation editors from another layer into this layer. + * This is typically used when duplicating a page - the editors from the + * source page are serialized and then deserialized into the new page's layer. + * + * @param {AnnotationEditorLayer} clonedFrom - The source annotation editor + * layer to clone editors from. If null or undefined, no action is taken. + * @returns {Promise} A promise that resolves when all editors have been + * cloned and added to this layer. + */ + async setClonedFrom(clonedFrom) { + if (!clonedFrom) { + return; + } + const promises = []; + for (const editor of clonedFrom.#allEditorsIterator) { + const serialized = editor.serialize(/* isForCopying = */ true); + if (!serialized) { + continue; + } + serialized.isCopy = false; + promises.push( + this.deserialize(serialized).then(deserialized => { + if (deserialized) { + this.addOrRebuild(deserialized); + } + }) + ); + } + await Promise.all(promises); } get isEmpty() { diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index 4b4cdcf61..fbb0432ce 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -948,7 +948,6 @@ class AnnotationEditorUIManager { evt => this.updateParams(evt.type, evt.value), { signal } ); - eventBus._on("pagesedited", this.onPagesEdited.bind(this), { signal }); window.addEventListener( "pointerdown", () => { @@ -1264,30 +1263,20 @@ class AnnotationEditorUIManager { } } - onPagesEdited({ pagesMapper }) { - for (const editor of this.#allEditors.values()) { - editor.updatePageIndex( - pagesMapper.getPrevPageNumber(editor.pageIndex + 1) - 1 - ); - } - const allLayers = this.#allLayers; - const newAllLayers = (this.#allLayers = new Map()); - for (const [pageIndex, layer] of allLayers) { - const prevPageIndex = pagesMapper.getPrevPageNumber(pageIndex + 1) - 1; - if (prevPageIndex === -1) { - // TODO: handle the case where the deletion of the page has been undone. - layer.destroy(); - continue; - } - newAllLayers.set(prevPageIndex, layer); - layer.updatePageIndex(prevPageIndex); - } - } - onPageChanging({ pageNumber }) { this.#currentPageIndex = pageNumber - 1; } + deletePage(id) { + for (const editor of this.getEditors(id)) { + editor.remove(); + } + this.#allLayers.delete(id); + if (this.#currentPageIndex === id) { + this.#currentPageIndex = 0; + } + } + focusMainContainer() { this.#container.focus(); } diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 937e28e46..cfcf83718 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -18,19 +18,21 @@ import { clearInput, closePages, createPromise, + createPromiseWithArgs, dragAndDrop, getAnnotationSelector, getRect, getThumbnailSelector, loadAndWait, scrollIntoView, + waitAndClick, waitForDOMMutation, } from "./test_utils.mjs"; async function waitForThumbnailVisible(page, pageNums) { await page.click("#viewsManagerToggleButton"); - const thumbSelector = "#thumbnailsView .thumbnailImage"; + const thumbSelector = "#thumbnailsView .thumbnailImageContainer > img"; await page.waitForSelector(thumbSelector, { visible: true }); if (!pageNums) { return null; @@ -45,18 +47,22 @@ async function waitForThumbnailVisible(page, pageNums) { ); } -function waitForPagesEdited(page) { - return createPromise(page, resolve => { - window.PDFViewerApplication.eventBus.on( - "pagesedited", - ({ pagesMapper }) => { +function waitForPagesEdited(page, type) { + return createPromiseWithArgs( + page, + resolve => { + const listener = ({ pagesMapper, type: ty }) => { + // eslint-disable-next-line no-undef + if (args[0] && args[0] !== ty) { + return; + } + window.PDFViewerApplication.eventBus.off("pagesedited", listener); resolve(Array.from(pagesMapper.getMapping())); - }, - { - once: true, - } - ); - }); + }; + window.PDFViewerApplication.eventBus.on("pagesedited", listener); + }, + [type] + ); } async function waitForHavingContents(page, expected) { @@ -533,7 +539,8 @@ describe("Reorganize Pages View", () => { await page.waitForSelector("#thumbnailsViewMenu", { visible: true }); await page.click("#thumbnailsViewMenu"); - const thumbSelector = "#thumbnailsView .thumbnailImage"; + const thumbSelector = + "#thumbnailsView .thumbnailImageContainer > img"; await page.waitForSelector(thumbSelector, { visible: true }); const rect1 = await getRect(page, getThumbnailSelector(1)); const rect2 = await getRect(page, getThumbnailSelector(2)); @@ -607,7 +614,7 @@ describe("Reorganize Pages View", () => { window.PDFViewerApplication.eventBus.on( "savepageseditedpdf", ({ data }) => { - resolve(Array.from(data.pageIndices)); + resolve(Array.from(data[0].pageIndices)); }, { once: true, @@ -630,4 +637,174 @@ describe("Reorganize Pages View", () => { ); }); }); + + describe("Delete some pages", () => { + 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 the pages are deleted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(3)}) input` + ); + + const handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionDelete"); + + const pageIndices = await awaitPromise(handlePagesEdited); + const expected = [ + 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + ]; + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual(expected); + await waitForHavingContents(page, expected); + }) + ); + }); + }); + + describe("Cut and paste some pages", () => { + 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 the pages has been cut and pasted correctly", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(3)}) input` + ); + + let handlePagesEdited = await waitForPagesEdited(page, "cut"); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionCut"); + + let pageIndices = await awaitPromise(handlePagesEdited); + let expected = [2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]; + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual(expected); + await waitForHavingContents(page, expected); + + handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, `${getThumbnailSelector(1)}+button`); + pageIndices = await awaitPromise(handlePagesEdited); + expected = [ + 2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + ]; + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual(expected); + await waitForHavingContents(page, expected); + }) + ); + }); + }); + + describe("Copy and paste some pages", () => { + 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 the pages has been copied and pasted correctly", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, 1); + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(3)}) input` + ); + + let handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionCopy"); + + let pageIndices = await awaitPromise(handlePagesEdited); + let expected = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + ]; + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual(expected); + await waitForHavingContents(page, expected); + + handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, `${getThumbnailSelector(2)}+button`); + pageIndices = await awaitPromise(handlePagesEdited); + expected = [ + 1, 2, 1, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + ]; + expect(pageIndices) + .withContext(`In ${browserName}`) + .toEqual(expected); + await waitForHavingContents(page, expected); + }) + ); + }); + }); }); diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index f7d0785da..e571d2f63 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -131,6 +131,15 @@ function createPromise(page, callback) { ); } +function createPromiseWithArgs(page, callback, args) { + return page.evaluateHandle( + // eslint-disable-next-line no-eval, no-shadow + (cb, args) => [new Promise(eval(`(${cb})`))], + callback.toString(), + args + ); +} + function awaitPromise(promise) { return promise.evaluate(([p]) => p); } @@ -253,7 +262,7 @@ function getAnnotationSelector(id) { } function getThumbnailSelector(pageNumber) { - return `.thumbnailImage[data-l10n-args='{"page":${pageNumber}}']`; + return `.thumbnailImageContainer[data-l10n-args='{"page":${pageNumber}}']`; } async function getSpanRectFromText(page, pageNumber, text) { @@ -963,6 +972,7 @@ export { countSerialized, countStorageEntries, createPromise, + createPromiseWithArgs, dragAndDrop, firstPageOnTop, FSI, diff --git a/test/integration/thumbnail_view_spec.mjs b/test/integration/thumbnail_view_spec.mjs index 88523cb91..7a5f7213e 100644 --- a/test/integration/thumbnail_view_spec.mjs +++ b/test/integration/thumbnail_view_spec.mjs @@ -9,7 +9,7 @@ import { function waitForThumbnailVisible(page, pageNum) { return page.waitForSelector( - `.thumbnailImage[data-l10n-args='{"page":${pageNum}}']`, + `.thumbnailImageContainer[data-l10n-args='{"page":${pageNum}}']`, { visible: true } ); } @@ -46,7 +46,8 @@ describe("PDF Thumbnail View", () => { pages.map(async ([browserName, page]) => { await page.click("#viewsManagerToggleButton"); - const thumbSelector = "#thumbnailsView .thumbnailImage"; + const thumbSelector = + "#thumbnailsView .thumbnailImageContainer > img"; await page.waitForSelector(thumbSelector, { visible: true }); await waitForThumbnailVisible(page, 1); @@ -110,12 +111,15 @@ describe("PDF Thumbnail View", () => { for (const pageNum of [14, 1, 13, 2]) { await goToPage(page, pageNum); - const thumbSelector = `.thumbnailImage[data-l10n-args='{"page":${pageNum}}']`; + const thumbSelector = `.thumbnailImageContainer[data-l10n-args='{"page":${pageNum}}']`; await page.waitForSelector( `.thumbnail ${thumbSelector}[aria-current="page"]`, { visible: true } ); - const src = await page.$eval(thumbSelector, el => el.src); + const src = await page.$eval( + `${thumbSelector} > img`, + el => el.src + ); expect(src) .withContext(`In ${browserName}`) .toMatch(/^blob:http:/); @@ -167,7 +171,7 @@ describe("PDF Thumbnail View", () => { expect( await isElementFocused( page, - `#thumbnailsView .thumbnailImage[data-l10n-args='{"page":1}']` + `#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":1}']` ) ) .withContext(`In ${browserName}`) @@ -177,7 +181,7 @@ describe("PDF Thumbnail View", () => { expect( await isElementFocused( page, - `#thumbnailsView .thumbnailImage[data-l10n-args='{"page":2}']` + `#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":2}']` ) ) .withContext(`In ${browserName}`) @@ -187,7 +191,7 @@ describe("PDF Thumbnail View", () => { expect( await isElementFocused( page, - `#thumbnailsView .thumbnailImage[data-l10n-args='{"page":1}']` + `#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":1}']` ) ) .withContext(`In ${browserName}`) @@ -198,7 +202,7 @@ describe("PDF Thumbnail View", () => { expect( await isElementFocused( page, - `#thumbnailsView .thumbnailImage[data-l10n-args='{"page":3}']` + `#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":3}']` ) ) .withContext(`In ${browserName}`) @@ -214,7 +218,7 @@ describe("PDF Thumbnail View", () => { expect( await isElementFocused( page, - `#thumbnailsView .thumbnailImage[data-l10n-args='{"page":14}']` + `#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":14}']` ) ) .withContext(`In ${browserName}`) @@ -224,7 +228,7 @@ describe("PDF Thumbnail View", () => { expect( await isElementFocused( page, - `#thumbnailsView .thumbnailImage[data-l10n-args='{"page":1}']` + `#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":1}']` ) ) .withContext(`In ${browserName}`) diff --git a/web/annotation_editor_layer_builder.js b/web/annotation_editor_layer_builder.js index b562c2542..0cbe58820 100644 --- a/web/annotation_editor_layer_builder.js +++ b/web/annotation_editor_layer_builder.js @@ -31,7 +31,7 @@ import { GenericL10n } from "web-null_l10n"; /** * @typedef {Object} AnnotationEditorLayerBuilderOptions * @property {AnnotationEditorUIManager} [uiManager] - * @property {PDFPageProxy} pdfPage + * @property {number} pageIndex * @property {L10n} [l10n] * @property {StructTreeLayerBuilder} [structTreeLayer] * @property {TextAccessibilityManager} [accessibilityManager] @@ -39,6 +39,7 @@ import { GenericL10n } from "web-null_l10n"; * @property {TextLayer} [textLayer] * @property {DrawLayer} [drawLayer] * @property {function} [onAppend] + * @property {AnnotationEditorLayer} [clonedFrom] */ /** @@ -60,11 +61,13 @@ class AnnotationEditorLayerBuilder { #uiManager; + #clonedFrom = null; + /** * @param {AnnotationEditorLayerBuilderOptions} options */ constructor(options) { - this.pdfPage = options.pdfPage; + this.pageIndex = options.pageIndex; this.accessibilityManager = options.accessibilityManager; this.l10n = options.l10n; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { @@ -79,6 +82,12 @@ class AnnotationEditorLayerBuilder { this.#drawLayer = options.drawLayer || null; this.#onAppend = options.onAppend || null; this.#structTreeLayer = options.structTreeLayer || null; + this.#clonedFrom = options.clonedFrom || null; + } + + updatePageIndex(newPageIndex) { + this.pageIndex = newPageIndex; + this.annotationEditorLayer?.updatePageIndex(newPageIndex); } /** @@ -113,7 +122,7 @@ class AnnotationEditorLayerBuilder { div, structTreeLayer: this.#structTreeLayer, accessibilityManager: this.accessibilityManager, - pageIndex: this.pdfPage.pageNumber - 1, + pageIndex: this.pageIndex, l10n: this.l10n, viewport: clonedViewport, annotationLayer: this.#annotationLayer, @@ -121,6 +130,11 @@ class AnnotationEditorLayerBuilder { drawLayer: this.#drawLayer, }); + this.annotationEditorLayer.setClonedFrom( + this.#clonedFrom?.annotationEditorLayer + ); + this.#clonedFrom = null; + const parameters = { viewport: clonedViewport, div, diff --git a/web/app.js b/web/app.js index 6da406881..d03aba3eb 100644 --- a/web/app.js +++ b/web/app.js @@ -2185,11 +2185,6 @@ const PDFViewerApplication = { ); } eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts); - eventBus._on( - "beforepagesedited", - this.onBeforePagesEdited.bind(this), - opts - ); eventBus._on( "savepageseditedpdf", this.onSavePagesEditedPDF.bind(this), @@ -2369,30 +2364,18 @@ const PDFViewerApplication = { await Promise.all([this.l10n?.destroy(), this.close()]); }, - onBeforePagesEdited(data) { - this.pdfViewer.onBeforePagesEdited(data); - }, - onPagesEdited(data) { this.pdfViewer.onPagesEdited(data); }, - async onSavePagesEditedPDF({ - data: { includePages, excludePages, pageIndices }, - }) { + async onSavePagesEditedPDF({ data: extractParams }) { 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]); + const modifiedPdfBytes = await this.pdfDocument.extractPages(extractParams); if (!modifiedPdfBytes) { console.error( "Something wrong happened when saving the edited PDF.\nPlease file a bug." diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index c37b5e4e2..db74a9ea5 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -19,8 +19,6 @@ import { RenderingCancelledException } from "pdfjs-lib"; class BasePDFPageView extends RenderableView { #loadingId = null; - #minDurationToUpdateCanvas = 0; - #renderError = null; #renderingState = RenderingStates.INITIAL; @@ -56,7 +54,7 @@ class BasePDFPageView extends RenderableView { this.renderingQueue = options.renderingQueue; this.enableOptimizedPartialRendering = options.enableOptimizedPartialRendering ?? false; - this.#minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500; + this.minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500; } get renderingState() { @@ -116,14 +114,14 @@ class BasePDFPageView extends RenderableView { this.#showCanvas = isLastShow => { if (updateOnFirstShow) { let tempCanvas = this.#tempCanvas; - if (!isLastShow && this.#minDurationToUpdateCanvas > 0) { + if (!isLastShow && this.minDurationToUpdateCanvas > 0) { // We draw on the canvas at 60fps (in using `requestAnimationFrame`), // so if the canvas is large, updating it at 60fps can be a way too // much and can cause some serious performance issues. // To avoid that we only update the canvas every // `this.#minDurationToUpdateCanvas` ms. - if (Date.now() - this.#startTime < this.#minDurationToUpdateCanvas) { + if (Date.now() - this.#startTime < this.minDurationToUpdateCanvas) { return; } if (!tempCanvas) { diff --git a/web/pdf_find_controller.js b/web/pdf_find_controller.js index 8c5fae22c..dbbc0f19a 100644 --- a/web/pdf_find_controller.js +++ b/web/pdf_find_controller.js @@ -422,6 +422,8 @@ class PDFFindController { #visitedPagesCount = 0; + #copiedExtractTextPromises = null; + /** * @param {PDFFindControllerOptions} options */ @@ -609,6 +611,7 @@ class PDFFindController { this._dirtyMatch = false; clearTimeout(this._findTimeout); this._findTimeout = null; + this.#copiedExtractTextPromises = null; this._firstPageCapability = Promise.withResolvers(); } @@ -1127,21 +1130,37 @@ class PDFFindController { } } - #onPagesEdited({ pagesMapper }) { + #onPagesEdited({ pagesMapper, type, pageNumbers }) { if (this._extractTextPromises.length === 0) { return; } + + if (type === "copy") { + this.#copiedExtractTextPromises = new Map(); + for (const pageNum of pageNumbers) { + this.#copiedExtractTextPromises.set( + pageNum, + this._extractTextPromises[pageNum - 1] + ); + } + return; + } + this.#onFindBarClose(); this._dirtyMatch = true; const prevTextPromises = this._extractTextPromises; const extractTextPromises = (this._extractTextPromises.length = []); - for (let i = 0, ii = pagesMapper.length; i < ii; i++) { - const prevPageIndex = pagesMapper.getPrevPageNumber(i + 1) - 1; - if (prevPageIndex === -1) { + for (let i = 1, ii = pagesMapper.length; i <= ii; i++) { + const prevPageNumber = pagesMapper.getPrevPageNumber(i); + if (prevPageNumber < 0) { + extractTextPromises.push( + this.#copiedExtractTextPromises?.get(-prevPageNumber) || + Promise.resolve() + ); continue; } extractTextPromises.push( - prevTextPromises[prevPageIndex] || Promise.resolve() + prevTextPromises[prevPageNumber - 1] || Promise.resolve() ); } } diff --git a/web/pdf_link_service.js b/web/pdf_link_service.js index 1b04f457a..cefced69c 100644 --- a/web/pdf_link_service.js +++ b/web/pdf_link_service.js @@ -85,7 +85,7 @@ class PDFLinkService { * @type {number} */ get pagesCount() { - return this.pdfDocument ? this.pdfDocument.numPages : 0; + return this.pdfDocument?.pagesMapper.pagesNumber || 0; } /** diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 4d956372b..2382f9342 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -103,6 +103,8 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js"; * @property {boolean} [enableAutoLinking] - Enable creation of hyperlinks from * text that look like URLs. The default value is `true`. * @property {CommentManager} [commentManager] - The comment manager instance. + * @property {PDFPageView} [clonedFrom] - The page view that is cloned + * to. */ const DEFAULT_LAYER_PROPERTIES = @@ -166,6 +168,8 @@ class PDFPageView extends BasePDFPageView { #layers = [null, null, null, null]; + #clonedFrom = null; + /** * @param {PDFPageViewOptions} options */ @@ -197,6 +201,7 @@ class PDFPageView extends BasePDFPageView { options.capCanvasAreaFactor ?? AppOptions.get("capCanvasAreaFactor"); this.#enableAutoLinking = options.enableAutoLinking !== false; this.#commentManager = options.commentManager || null; + this.#clonedFrom = options.clonedFrom || null; this.l10n = options.l10n; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { @@ -226,7 +231,6 @@ class PDFPageView extends BasePDFPageView { div.setAttribute("data-l10n-id", "pdfjs-page-landmark"); div.setAttribute("data-l10n-args", JSON.stringify({ page: this.id })); this.div = div; - this.#setDimensions(); container?.append(div); @@ -270,6 +274,35 @@ class PDFPageView extends BasePDFPageView { } } + clone(id) { + const clone = new PDFPageView({ + container: null, + eventBus: this.eventBus, + pagesColors: this.pageColors, + renderingQueue: this.renderingQueue, + enableOptimizedPartialRendering: this.enableOptimizedPartialRendering, + minDurationToUpdateCanvas: this.minDurationToUpdateCanvas, + defaultViewport: this.viewport, + id, + layerProperties: this.#layerProperties, + scale: this.scale, + optionalContentConfigPromise: this._optionalContentConfigPromise, + textLayerMode: this.#textLayerMode, + annotationMode: this.#annotationMode, + imageResourcesPath: this.imageResourcesPath, + enableDetailCanvas: this.enableDetailCanvas, + maxCanvasPixels: this.maxCanvasPixels, + maxCanvasDim: this.maxCanvasDim, + capCanvasAreaFactor: this.capCanvasAreaFactor, + enableAutoLinking: this.#enableAutoLinking, + commentManager: this.#commentManager, + l10n: this.l10n, + clonedFrom: this, + }); + clone.setPdfPage(this.pdfPage); + return clone; + } + #addLayer(div, name) { const pos = LAYERS_ORDER.get(name); const oldDiv = this.#layers[pos]; @@ -331,6 +364,7 @@ class PDFPageView extends BasePDFPageView { this._textHighlighter.pageIdx = newPageNumber - 1; // Don't update the page index for the draw layer, since it's just used as // an identifier. + this.annotationEditorLayer?.updatePageIndex(newPageNumber - 1); } setPdfPage(pdfPage) { @@ -378,6 +412,15 @@ class PDFPageView extends BasePDFPageView { this.pdfPage?.cleanup(); } + deleteMe(isCut) { + if (isCut) { + this.div.remove(); + return; + } + this.destroy(); + this.#layerProperties.annotationEditorUIManager?.deletePage(this.id); + } + hasEditableAnnotations() { return !!this.annotationLayer?.hasEditableAnnotations(); } @@ -1140,17 +1183,19 @@ class PDFPageView extends BasePDFPageView { ) { this.annotationEditorLayer ||= new AnnotationEditorLayerBuilder({ uiManager: annotationEditorUIManager, - pdfPage, + pageIndex: this.id - 1, l10n, structTreeLayer: this.structTreeLayer, accessibilityManager: this._accessibilityManager, annotationLayer: this.annotationLayer?.annotationLayer, textLayer: this.textLayer, drawLayer: this.drawLayer.getDrawLayer(), + clonedFrom: this.#clonedFrom?.annotationEditorLayer, onAppend: annotationEditorLayerDiv => { this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); }, }); + this.#clonedFrom = null; this.#renderAnnotationEditorLayer(); } }); diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index 61430667b..4555319e4 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -116,10 +116,10 @@ class PDFThumbnailView extends RenderableView { this.placeholder = null; - const imageContainer = (this.div = document.createElement("div")); - imageContainer.className = "thumbnail"; - imageContainer.setAttribute("page-number", id); - imageContainer.setAttribute("page-id", id); + const thumbnailContainer = (this.div = document.createElement("div")); + thumbnailContainer.className = "thumbnail"; + thumbnailContainer.setAttribute("page-number", id); + thumbnailContainer.setAttribute("page-id", id); if (enableSplitMerge) { const checkbox = (this.checkbox = document.createElement("input")); @@ -127,24 +127,80 @@ class PDFThumbnailView extends RenderableView { checkbox.tabIndex = -1; checkbox.setAttribute("data-l10n-id", "pdfjs-thumb-page-checkbox"); checkbox.setAttribute("data-l10n-args", this.#pageL10nArgs); - imageContainer.append(checkbox); + thumbnailContainer.append(checkbox); + this.pasteButton = null; } + const imageContainer = (this.imageContainer = + document.createElement("div")); + thumbnailContainer.append(imageContainer); + imageContainer.classList.add( + "thumbnailImageContainer", + "missingThumbnailImage" + ); + imageContainer.role = "button"; + imageContainer.tabIndex = -1; + imageContainer.draggable = false; + imageContainer.setAttribute("page-number", id); + const image = (this.image = document.createElement("img")); - image.classList.add("thumbnailImage", "missingThumbnailImage"); - image.role = "button"; - image.tabIndex = -1; - image.draggable = false; + imageContainer.append(image); this.#updateDims(); - imageContainer.append(image); - container.append(imageContainer); + container.append(thumbnailContainer); + } + + clone(container, id) { + const thumbnailView = new PDFThumbnailView({ + container, + id, + eventBus: this.eventBus, + defaultViewport: this.viewport, + optionalContentConfigPromise: this._optionalContentConfigPromise, + linkService: this.linkService, + renderingQueue: this.renderingQueue, + maxCanvasPixels: this.maxCanvasPixels, + maxCanvasDim: this.maxCanvasDim, + pageColors: this.pageColors, + enableSplitMerge: !!this.checkbox, + }); + thumbnailView.setPdfPage(this.pdfPage); + const { imageContainer } = this; + if (!imageContainer.classList.contains("missingThumbnailImage")) { + thumbnailView.image.replaceWith(this.image.cloneNode(true)); + thumbnailView.imageContainer.classList.remove("missingThumbnailImage"); + } + return thumbnailView; + } + + addPasteButton(pasteCallback) { + if (this.pasteButton) { + return; + } + const pasteButton = (this.pasteButton = document.createElement("button")); + pasteButton.classList.add("thumbnailPasteButton", "viewsManagerButton"); + pasteButton.tabIndex = 0; + const span = document.createElement("span"); + span.setAttribute("data-l10n-id", "pdfjs-views-manager-paste-button-label"); + pasteButton.append(span); + pasteButton.addEventListener("click", () => { + pasteCallback(this.id); + }); + + this.imageContainer.after(pasteButton); + } + + toggleSelected(isSelected) { + if (this.checkbox) { + this.checkbox.checked = isSelected; + } } updateId(newId) { this.id = newId; this.renderingId = `thumbnail${newId}`; this.div.setAttribute("page-number", newId); + this.imageContainer.setAttribute("page-number", newId); // TODO: do we set the page label ? this.setPageLabel(this.pageLabel); } @@ -157,7 +213,7 @@ class PDFThumbnailView extends RenderableView { const canvasHeight = (this.canvasHeight = (canvasWidth / ratio) | 0); this.scale = canvasWidth / width; - this.image.style.height = `${canvasHeight}px`; + this.imageContainer.style.height = `${canvasHeight}px`; } get renderingState() { @@ -181,17 +237,23 @@ class PDFThumbnailView extends RenderableView { this.renderingState = RenderingStates.INITIAL; this.#updateDims(); - const { image } = this; + const { image, imageContainer } = this; const url = image.src; if (url) { URL.revokeObjectURL(url); - image.removeAttribute("data-l10n-id"); - image.removeAttribute("data-l10n-args"); image.src = ""; - this.image.classList.add("missingThumbnailImage"); + imageContainer.removeAttribute("data-l10n-id"); + imageContainer.removeAttribute("data-l10n-args"); + imageContainer.classList.add("missingThumbnailImage"); } } + destroy() { + this.reset(); + this.toggleCurrent(false); + this.div.remove(); + } + update({ rotation = null }) { if (typeof rotation === "number") { this.rotation = rotation; // The rotation may be zero. @@ -205,12 +267,13 @@ class PDFThumbnailView extends RenderableView { } toggleCurrent(isCurrent) { + const { imageContainer } = this; if (isCurrent) { - this.image.ariaCurrent = "page"; - this.image.tabIndex = 0; + imageContainer.ariaCurrent = "page"; + imageContainer.tabIndex = 0; } else { - this.image.ariaCurrent = false; - this.image.tabIndex = -1; + imageContainer.ariaCurrent = false; + imageContainer.tabIndex = -1; } } @@ -257,14 +320,14 @@ class PDFThumbnailView extends RenderableView { throw new Error("#convertCanvasToImage: Rendering has not finished."); } const reducedCanvas = this.#reduceImage(canvas); - const { image } = this; + const { imageContainer, image } = this; const { promise, resolve } = Promise.withResolvers(); reducedCanvas.toBlob(resolve); const blob = await promise; image.src = URL.createObjectURL(blob); - image.setAttribute("data-l10n-id", "pdfjs-thumb-page-canvas"); - image.setAttribute("data-l10n-args", this.#pageL10nArgs); - image.classList.remove("missingThumbnailImage"); + imageContainer.setAttribute("data-l10n-id", "pdfjs-thumb-page-canvas"); + imageContainer.setAttribute("data-l10n-args", this.#pageL10nArgs); + imageContainer.classList.remove("missingThumbnailImage"); if (!FeatureTest.isOffscreenCanvasSupported) { // Clean up the canvas element since it is no longer needed. reducedCanvas.width = reducedCanvas.height = 0; @@ -465,7 +528,7 @@ class PDFThumbnailView extends RenderableView { */ setPageLabel(label) { this.pageLabel = typeof label === "string" ? label : null; - this.image.setAttribute("data-l10n-args", this.#pageL10nArgs); + this.imageContainer.setAttribute("data-l10n-args", this.#pageL10nArgs); this.checkbox?.setAttribute("data-l10n-args", this.#pageL10nArgs); } } diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 66a07e097..5658b872d 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -114,6 +114,18 @@ class PDFThumbnailViewer { #manageSaveAsButton = null; + #manageDeleteButton = null; + + #manageCopyButton = null; + + #manageCutButton = null; + + #copiedThumbnails = null; + + #copiedPageNumbers = null; + + #isCut = false; + /** * @param {PDFThumbnailViewerOptions} options */ @@ -143,6 +155,14 @@ class PDFThumbnailViewer { if (this.#enableSplitMerge && manageMenu) { const { button, menu, copy, cut, delete: del, saveAs } = manageMenu; + this.eventBus.on( + "pagesloaded", + () => { + button.disabled = false; + }, + { once: true } + ); + this._manageMenu = new Menu(menu, button, [copy, cut, del, saveAs]); this.#manageSaveAsButton = saveAs; saveAs.addEventListener("click", () => { @@ -151,6 +171,15 @@ class PDFThumbnailViewer { data: this.#pagesMapper.getPageMappingForSaving(), }); }); + this.#manageDeleteButton = del; + del.addEventListener("click", this.#deletePages.bind(this)); + this.#manageCopyButton = copy; + copy.addEventListener("click", this.#copyPages.bind(this)); + this.#manageCutButton = cut; + cut.addEventListener("click", this.#cutPages.bind(this)); + + this.#toggleMenuEntries(false); + button.disabled = true; } else { manageMenu.button.hidden = true; } @@ -191,7 +220,7 @@ class PDFThumbnailViewer { } if (pageNumber !== this._currentPageNumber) { const prevThumbnailView = this._thumbnails[this._currentPageNumber - 1]; - prevThumbnailView.toggleCurrent(/* isCurrent = */ false); + prevThumbnailView?.toggleCurrent(/* isCurrent = */ false); thumbnailView.toggleCurrent(/* isCurrent = */ true); this._currentPageNumber = pageNumber; } @@ -200,15 +229,11 @@ class PDFThumbnailViewer { // If the thumbnail isn't currently visible, scroll it into view. if (views.length > 0) { let shouldScroll = false; - if ( - pageNumber <= this.#pagesMapper.getPageNumber(first.id) || - pageNumber >= this.#pagesMapper.getPageNumber(last.id) - ) { + if (pageNumber <= first.id || pageNumber >= last.id) { shouldScroll = true; } else { for (const { id, percent } of views) { - const mappedPageNumber = this.#pagesMapper.getPageNumber(id); - if (mappedPageNumber !== pageNumber) { + if (id !== pageNumber) { continue; } shouldScroll = percent < 100; @@ -403,24 +428,45 @@ class PDFThumbnailViewer { )); } - #updateThumbnails() { + #updateThumbnails(currentPageNumber) { + let newCurrentPageNumber = 0; const pagesMapper = this.#pagesMapper; this.container.replaceChildren(); const prevThumbnails = this._thumbnails; const newThumbnails = (this._thumbnails = []); const fragment = document.createDocumentFragment(); - for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) { - const prevPageIndex = pagesMapper.getPrevPageNumber(i + 1) - 1; - if (prevPageIndex === -1) { + 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); + fragment.append(thumbnail.div); + } else { + thumbnail = thumbnail.clone(fragment, i); + } + newThumbnails.push(thumbnail); continue; } - const newThumbnail = prevThumbnails[prevPageIndex]; + if (prevPageNumber === currentPageNumber) { + newCurrentPageNumber = i; + } + const newThumbnail = prevThumbnails[prevPageNumber - 1]; newThumbnails.push(newThumbnail); - newThumbnail.updateId(i + 1); + 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(); + } + return newCurrentPageNumber; } #onStartDragging(draggedThumbnail) { @@ -443,18 +489,18 @@ class PDFThumbnailViewer { const thumbnail = this._thumbnails[selected - 1]; const placeholder = (thumbnail.placeholder = document.createElement("div")); - placeholder.classList.add("thumbnailImage", "placeholder"); - const { div, image } = thumbnail; + placeholder.classList.add("thumbnailImageContainer", "placeholder"); + const { div, imageContainer } = thumbnail; div.classList.add("isDragging"); - placeholder.style.height = getComputedStyle(image).height; - image.after(placeholder); + placeholder.style.height = getComputedStyle(imageContainer).height; + imageContainer.after(placeholder); if (selected !== startPageNumber) { - image.classList.add("hidden"); + imageContainer.classList.add("hidden"); continue; } if (this.#selectedPages.size === 1) { - image.classList.add("draggingThumbnail"); - this.#draggedContainer = image; + imageContainer.classList.add("draggingThumbnail"); + this.#draggedContainer = imageContainer; continue; } // For multiple selected thumbnails, only the one being dragged is shown @@ -463,13 +509,13 @@ class PDFThumbnailViewer { document.createElement("div")); draggedContainer.classList.add( "draggingThumbnail", - "thumbnailImage", + "thumbnailImageContainer", "multiple" ); - draggedContainer.style.height = getComputedStyle(image).height; - image.replaceWith(draggedContainer); - image.classList.remove("thumbnailImage"); - draggedContainer.append(image); + draggedContainer.style.height = getComputedStyle(imageContainer).height; + imageContainer.replaceWith(draggedContainer); + imageContainer.classList.remove("thumbnailImageContainer"); + draggedContainer.append(imageContainer); draggedContainer.setAttribute( "data-multiple-count", this.#selectedPages.size @@ -490,17 +536,17 @@ class PDFThumbnailViewer { this.container.classList.remove("isDragging"); for (const selected of this.#selectedPages) { const thumbnail = this._thumbnails[selected - 1]; - const { div, placeholder, image } = thumbnail; + const { div, placeholder, imageContainer } = thumbnail; placeholder.remove(); - image.classList.remove("draggingThumbnail", "hidden"); + imageContainer.classList.remove("draggingThumbnail", "hidden"); div.classList.remove("isDragging"); } if (draggedContainer.classList.contains("multiple")) { // Restore the dragged image to its thumbnail. - const originalImage = draggedContainer.firstElementChild; - draggedContainer.replaceWith(originalImage); - originalImage.classList.add("thumbnailImage"); + const originalImageContainer = draggedContainer.firstElementChild; + draggedContainer.replaceWith(originalImageContainer); + originalImageContainer.classList.add("thumbnailImageContainer"); } else { draggedContainer.style.translate = ""; } @@ -515,45 +561,36 @@ class PDFThumbnailViewer { selectedPages.has(lastDraggedOverIndex + 2)) ) ) { + this._thumbnails[this._currentPageNumber - 1]?.toggleCurrent( + /* isCurrent = */ false + ); + this._currentPageNumber = -1; + const newIndex = lastDraggedOverIndex + 1; const pagesToMove = Array.from(selectedPages).sort((a, b) => a - b); const pagesMapper = this.#pagesMapper; - const currentPageId = pagesMapper.getPageId(this._currentPageNumber); - const newCurrentPageId = pagesMapper.getPageId( - isNaN(this.#pageNumberToRemove) - ? pagesToMove[0] - : this.#pageNumberToRemove - ); - - this.eventBus.dispatch("beforepagesedited", { - source: this, - pagesMapper, - }); + let currentPageNumber = isNaN(this.#pageNumberToRemove) + ? pagesToMove[0] + : this.#pageNumberToRemove; pagesMapper.movePages(selectedPages, pagesToMove, newIndex); - this.#updateThumbnails(); - - this._currentPageNumber = pagesMapper.getPageNumber(currentPageId); + currentPageNumber = this.#updateThumbnails(currentPageNumber); this.#computeThumbnailsPosition(); selectedPages.clear(); this.#pageNumberToRemove = NaN; + this.#updateMenuEntries(); - const isIdentity = (this.#manageSaveAsButton.disabled = - !this.#pagesMapper.hasBeenAltered()); - if (!isIdentity) { - this.eventBus.dispatch("pagesedited", { - source: this, - pagesMapper, - index: newIndex, - pagesToMove, - }); - } + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper, + type: "move", + }); - const newCurrentPageNumber = pagesMapper.getPageNumber(newCurrentPageId); setTimeout(() => { - this.linkService.goToPage(newCurrentPageNumber); + this.forceRendering(); + this.linkService.goToPage(currentPageNumber); }, 0); } @@ -563,6 +600,121 @@ class PDFThumbnailViewer { } } + #clearSelection() { + for (const pageNumber of this.#selectedPages) { + this._thumbnails[pageNumber - 1].toggleSelected(false); + } + this.#selectedPages.clear(); + } + + #copyPages(clearSelection = true) { + const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from( + this.#selectedPages + ).sort((a, b) => a - b)); + const pagesMapper = this.#pagesMapper; + pagesMapper.copyPages(pageNumbersToCopy); + this.#copiedThumbnails ||= new Map(); + for (const pageNumber of pageNumbersToCopy) { + this.#copiedThumbnails.set(pageNumber, this._thumbnails[pageNumber - 1]); + } + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper, + pageNumbers: pageNumbersToCopy, + type: "copy", + }); + if (clearSelection) { + this.#clearSelection(); + } + for (const thumbnail of this._thumbnails) { + thumbnail.addPasteButton(this.#pastePages.bind(this)); + } + this.container.classList.add("pasteMode"); + this.#toggleMenuEntries(false); + } + + #cutPages() { + this.#isCut = true; + this.#copyPages(false); + this.#deletePages(/* type = */ "cut"); + } + + #pastePages(index) { + this.container.classList.remove("pasteMode"); + this.#toggleMenuEntries(true); + + const pagesMapper = this.#pagesMapper; + let currentPageNumber = this.#copiedPageNumbers.includes( + this._currentPageNumber + ) + ? 0 + : this._currentPageNumber; + + pagesMapper.pastePages(index); + currentPageNumber = this.#updateThumbnails(currentPageNumber); + + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper, + hasBeenCut: this.#isCut, + type: "paste", + }); + + this.#copiedThumbnails = null; + this.#isCut = false; + this.#updateMenuEntries(); + + setTimeout(() => { + this.forceRendering(); + this.linkService.goToPage(currentPageNumber || 1); + }, 0); + } + + #deletePages(type = "delete") { + const selectedPages = this.#selectedPages; + if (selectedPages.size === 0) { + return; + } + const pagesMapper = this.#pagesMapper; + let currentPageNumber = selectedPages.has(this._currentPageNumber) + ? 0 + : this._currentPageNumber; + const pagesToDelete = Uint32Array.from(selectedPages).sort((a, b) => a - b); + + pagesMapper.deletePages(pagesToDelete); + currentPageNumber = this.#updateThumbnails(currentPageNumber); + selectedPages.clear(); + this.#updateMenuEntries(); + + this.eventBus.dispatch("pagesedited", { + source: this, + pagesMapper, + pageNumbers: pagesToDelete, + type, + }); + + setTimeout(() => { + this.forceRendering(); + this.linkService.goToPage(currentPageNumber || 1); + }, 0); + } + + #updateMenuEntries() { + this.#manageSaveAsButton.disabled = !this.#pagesMapper.hasBeenAltered(); + this.#manageDeleteButton.disabled = + this.#manageCopyButton.disabled = + this.#manageCutButton.disabled = + !this.#selectedPages?.size; + } + + #toggleMenuEntries(enable) { + this.#manageSaveAsButton.disabled = + this.#manageDeleteButton.disabled = + this.#manageCopyButton.disabled = + this.#manageCutButton.disabled = + !enable; + } + #moveDraggedContainer(dx, dy) { this.#draggedImageOffsetX += dx; this.#draggedImageOffsetY += dy; @@ -714,11 +866,11 @@ class PDFThumbnailViewer { stopEvent(e); break; case "Home": - this._thumbnails[0].image.focus(); + this._thumbnails[0].imageContainer.focus(); stopEvent(e); break; case "End": - this._thumbnails.at(-1).image.focus(); + this._thumbnails.at(-1).imageContainer.focus(); stopEvent(e); break; case "Enter": @@ -749,6 +901,7 @@ class PDFThumbnailViewer { } else { set.delete(pageNumber); } + this.#updateMenuEntries(); } #addDragListeners() { @@ -763,8 +916,9 @@ class PDFThumbnailViewer { pointerId: dragPointerId, } = e; if ( + this.#pagesMapper.copiedPageNumbers?.length > 0 || !isNaN(this.#lastDraggedOverIndex) || - !draggedImage.classList.contains("thumbnailImage") + !draggedImage.classList.contains("thumbnailImageContainer") ) { // We're already handling a drag, or the target is not draggable. return; @@ -884,7 +1038,7 @@ class PDFThumbnailViewer { #goToPage(e) { const { target } = e; - if (target.classList.contains("thumbnailImage")) { + if (target.classList.contains("thumbnailImageContainer")) { const pageNumber = parseInt( target.parentElement.getAttribute("page-number"), 10 @@ -943,7 +1097,7 @@ class PDFThumbnailViewer { } } if (nextThumbnail) { - nextThumbnail.image.focus(); + nextThumbnail.imageContainer.focus(); } } diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index e938136ab..c22ede2c2 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -289,6 +289,8 @@ class PDFViewer { #viewerAlert = null; + #copiedPageViews = null; + /** * @param {PDFViewerOptions} options */ @@ -1173,23 +1175,44 @@ class PDFViewer { }); } - async onBeforePagesEdited({ pagesMapper }) { - await this._pagesCapability.promise; - this._currentPageId = pagesMapper.getPageId(this._currentPageNumber); - } + onPagesEdited({ pagesMapper, type, hasBeenCut, pageNumbers }) { + if (type === "copy") { + this.#copiedPageViews = new Map(); + for (const pageNum of pageNumbers) { + this.#copiedPageViews.set(pageNum, this._pages[pageNum - 1]); + } + return; + } - onPagesEdited({ pagesMapper }) { - this._currentPageNumber = pagesMapper.getPageNumber(this._currentPageId); + const isCut = type === "cut"; + if (isCut || type === "delete") { + for (const pageNum of pageNumbers) { + this._pages[pageNum - 1].deleteMe(isCut); + } + } + + this._currentPageNumber = 0; const prevPages = this._pages; const newPages = (this._pages = []); - for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) { - const prevPageNumber = pagesMapper.getPrevPageNumber(i + 1) - 1; - if (prevPageNumber === -1) { + for (let i = 1, ii = pagesMapper.pagesNumber; i <= ii; i++) { + const prevPageNumber = pagesMapper.getPrevPageNumber(i); + if (prevPageNumber < 0) { + let page = this.#copiedPageViews.get(-prevPageNumber); + if (hasBeenCut) { + page.updatePageNumber(i); + } else { + page = page.clone(i); + } + newPages.push(page); continue; } - const page = prevPages[prevPageNumber]; - newPages[i] = page; - page.updatePageNumber(i + 1); + const page = prevPages[prevPageNumber - 1]; + newPages.push(page); + page.updatePageNumber(i); + } + + if (!isCut) { + this.#copiedPageViews = null; } const viewerElement = @@ -1204,6 +1227,7 @@ class PDFViewer { } viewerElement.append(fragment); } + setTimeout(() => { this.forceRendering(); }); diff --git a/web/views_manager.css b/web/views_manager.css index d9c17cf66..1a81554db 100644 --- a/web/views_manager.css +++ b/web/views_manager.css @@ -535,7 +535,7 @@ cursor: grabbing; > .thumbnail { - > .thumbnailImage:hover { + > .thumbnailImageContainer:hover { cursor: grabbing; &:not([aria-current="page"]) { @@ -557,6 +557,35 @@ } } + &.pasteMode { + > .thumbnail { + flex-direction: column; + + > input { + display: none; + } + + > .thumbnailPasteButton { + display: flex; + justify-content: center; + align-items: center; + border-radius: 16px; + min-height: 24px; + padding: 4px 16px; + + font: menu; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + } + } + + &:not(.pasteMode) > .thumbnail > .thumbnailPasteButton { + display: none; + } + > .thumbnail { display: inline-flex; justify-content: center; @@ -567,14 +596,19 @@ position: relative; scroll-margin-top: 20px; - &:not(.isDragging)::after { + .thumbnailPasteButton { + padding: 8px 0; + text-align: center; + } + + &:not(.isDragging) > .thumbnailImageContainer::after { content: attr(page-number); border-radius: 8px; background-color: var(--image-page-number-bg); color: var(--image-page-number-fg); position: absolute; bottom: 5px; - inset-inline-end: calc(var(--thumbnail-width) / 2); + inset-inline-end: 50%; min-width: 32px; height: 16px; text-align: center; @@ -590,7 +624,8 @@ user-select: none; } - &:has([aria-current="page"]):not(.isDragging)::after { + &:has([aria-current="page"]):not(.isDragging) + > .thumbnailImageContainer::after { background-color: var(--image-current-page-number-bg); color: var(--image-current-page-number-fg); } @@ -603,7 +638,7 @@ margin: 0; } - > .thumbnailImage { + > .thumbnailImageContainer { --thumbnail-dragging-scale: 1.4; width: var(--thumbnail-width); @@ -613,6 +648,17 @@ box-sizing: content-box; outline: var(--image-outline); user-select: none; + position: relative; + + img { + width: 100%; + height: 100%; + border: inherit; + border-radius: inherit; + outline: none; + user-select: none; + pointer-events: none; + } &.missingThumbnailImage { content-visibility: hidden; @@ -658,7 +704,7 @@ &.multiple { box-shadow: var(--image-multiple-dragging-shadow); - > img { + > .thumbnailImageContainer { position: absolute; top: 0; left: 0;