Add a UI to undo cut/delete and cancel a copy (bug 2021352, bug 2010832)

This happens in a bar on top of the thumbnails sidebar.
The label depending on the selected thumbnails is fixed.
This commit is contained in:
Calixte Denizet 2026-03-05 16:42:22 +01:00
parent 46f4bc805e
commit 0e48c16c3c
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
12 changed files with 748 additions and 43 deletions

View File

@ -764,6 +764,7 @@ pdfjs-views-manager-status-warning-copy-label = Couldnt copy. Refresh page an
pdfjs-views-manager-status-warning-delete-label = Couldnt delete. Refresh page and try again. pdfjs-views-manager-status-warning-delete-label = Couldnt delete. Refresh page and try again.
pdfjs-views-manager-status-warning-save-label = Couldnt save. Refresh page and try again. pdfjs-views-manager-status-warning-save-label = Couldnt save. Refresh page and try again.
pdfjs-views-manager-status-undo-button-label = Undo pdfjs-views-manager-status-undo-button-label = Undo
pdfjs-views-manager-status-done-button-label = Done
pdfjs-views-manager-status-close-button = pdfjs-views-manager-status-close-button =
.title = Close .title = Close
pdfjs-views-manager-status-close-button-label = Close pdfjs-views-manager-status-close-button-label = Close

View File

@ -2406,6 +2406,8 @@ class WorkerTransport {
#copiedPageInfo = null; #copiedPageInfo = null;
#savedPageInfo = null;
constructor( constructor(
messageHandler, messageHandler,
loadingTask, loadingTask,
@ -2477,13 +2479,36 @@ class WorkerTransport {
return; return;
} }
if (type === "cancelCopy") {
this.#copiedPageInfo = null;
return;
}
if (type === "delete") { if (type === "delete") {
this.#savedPageInfo = {
pageCache: new Map(this.#pageCache),
pagePromises: new Map(this.#pagePromises),
};
for (const pageNum of pageNumbers) { for (const pageNum of pageNumbers) {
this.#pageCache.delete(pageNum - 1); this.#pageCache.delete(pageNum - 1);
this.#pagePromises.delete(pageNum - 1); this.#pagePromises.delete(pageNum - 1);
} }
} }
if (type === "cancelDelete") {
if (this.#savedPageInfo) {
this.#pageCache = this.#savedPageInfo.pageCache;
this.#pagePromises = this.#savedPageInfo.pagePromises;
this.#savedPageInfo = null;
}
return;
}
if (type === "cleanSavedData") {
this.#savedPageInfo = null;
return;
}
const newPageCache = new Map(); const newPageCache = new Map();
const newPromiseCache = new Map(); const newPromiseCache = new Map();
const { pagesMapper } = this; const { pagesMapper } = this;

View File

@ -1082,6 +1082,8 @@ class PagesMapper {
*/ */
#copiedPageNumbers = null; #copiedPageNumbers = null;
#savedData = null;
/** /**
* Gets the total number of pages. * Gets the total number of pages.
* @returns {number} The number of pages. * @returns {number} The number of pages.
@ -1253,6 +1255,13 @@ class PagesMapper {
const pageNumberToId = this.#pageNumberToId; const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#idToPageNumber; const prevIdToPageNumber = this.#idToPageNumber;
this.#savedData = {
pageNumberToId: pageNumberToId.slice(),
idToPageNumber: new Map(prevIdToPageNumber),
pageNumber: this.#pagesNumber,
prevPageNumbers: this.#prevPageNumbers.slice(),
};
this.pagesNumber -= pagesToDelete.length; this.pagesNumber -= pagesToDelete.length;
this.#init(false); this.#init(false);
const newPageNumberToId = this.#pageNumberToId; const newPageNumberToId = this.#pageNumberToId;
@ -1279,6 +1288,22 @@ class PagesMapper {
this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete }); this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete });
} }
cancelDelete() {
if (this.#savedData) {
this.#pageNumberToId = this.#savedData.pageNumberToId;
this.#idToPageNumber = this.#savedData.idToPageNumber;
this.pagesNumber = this.#savedData.pageNumber;
this.#prevPageNumbers = this.#savedData.prevPageNumbers;
this.#savedData = null;
this.#updateListeners({ type: "cancelDelete" });
}
}
cleanSavedData() {
this.#savedData = null;
this.#updateListeners({ type: "cleanSavedData" });
}
/** /**
* Copies a set of pages while keeping IDnumber mappings in sync. * Copies a set of pages while keeping IDnumber mappings in sync.
* @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed). * @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed).
@ -1292,6 +1317,12 @@ class PagesMapper {
this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy }); this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy });
} }
cancelCopy() {
this.#copiedPageIds = null;
this.#copiedPageNumbers = null;
this.#updateListeners({ type: "cancelCopy" });
}
/** /**
* Pastes a set of pages while keeping IDnumber mappings in sync. * Pastes a set of pages while keeping IDnumber mappings in sync.
* @param {number} index - Zero-based insertion index in the page-number list. * @param {number} index - Zero-based insertion index in the page-number list.
@ -1323,6 +1354,7 @@ class PagesMapper {
this.#updateListeners({ type: "paste" }); this.#updateListeners({ type: "paste" });
this.#copiedPageIds = null; this.#copiedPageIds = null;
this.#copiedPageNumbers = null;
} }
/** /**
@ -1455,7 +1487,7 @@ class PagesMapper {
} }
getMapping() { getMapping() {
return this.#pageNumberToId.subarray(0, this.pagesNumber); return this.#pageNumberToId?.subarray(0, this.pagesNumber);
} }
} }

View File

@ -33,6 +33,7 @@ import {
showViewsManager, showViewsManager,
waitAndClick, waitAndClick,
waitForDOMMutation, waitForDOMMutation,
waitForTextToBe,
} from "./test_utils.mjs"; } from "./test_utils.mjs";
async function waitForThumbnailVisible(page, pageNums) { async function waitForThumbnailVisible(page, pageNums) {
@ -63,7 +64,7 @@ function waitForPagesEdited(page, type) {
return; return;
} }
window.PDFViewerApplication.eventBus.off("pagesedited", listener); window.PDFViewerApplication.eventBus.off("pagesedited", listener);
resolve(Array.from(pagesMapper.getMapping())); resolve(Array.from(pagesMapper.getMapping() || []));
}; };
window.PDFViewerApplication.eventBus.on("pagesedited", listener); window.PDFViewerApplication.eventBus.on("pagesedited", listener);
}, },
@ -164,7 +165,7 @@ describe("Reorganize Pages View", () => {
continue; continue;
} }
for (const node of mutation.addedNodes) { for (const node of mutation.addedNodes) {
if (node.classList.contains("dragMarker")) { if (node.classList?.contains("dragMarker")) {
return true; return true;
} }
} }
@ -180,7 +181,7 @@ describe("Reorganize Pages View", () => {
continue; continue;
} }
for (const node of mutation.removedNodes) { for (const node of mutation.removedNodes) {
if (node.classList.contains("dragMarker")) { if (node.classList?.contains("dragMarker")) {
return true; return true;
} }
} }
@ -559,7 +560,7 @@ describe("Reorganize Pages View", () => {
continue; continue;
} }
for (const node of mutation.addedNodes) { for (const node of mutation.addedNodes) {
if (node.classList.contains("dragMarker")) { if (node.classList?.contains("dragMarker")) {
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
return rect.width !== 0; return rect.width !== 0;
} }
@ -1182,6 +1183,401 @@ describe("Reorganize Pages View", () => {
}); });
}); });
describe("Status label reflects number of checked thumbnails (bug 2010832)", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should update the status label when thumbnails are checked or unchecked", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
const labelSelector = "#viewsManagerStatusActionLabel";
// Initially no pages are selected.
await waitForTextToBe(page, labelSelector, "Select pages");
// Check thumbnail 1: label should read "1 selected".
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
await waitForTextToBe(page, labelSelector, `${FSI}1${PDI} selected`);
// Check thumbnail 2: label should read "2 selected".
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(2)}) input`
);
await waitForTextToBe(page, labelSelector, `${FSI}2${PDI} selected`);
// Uncheck thumbnail 1: label should read "1 selected".
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
await waitForTextToBe(page, labelSelector, `${FSI}1${PDI} selected`);
// Uncheck thumbnail 2: label should revert to "Select pages".
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(2)}) input`
);
await waitForTextToBe(page, labelSelector, "Select pages");
})
);
});
});
describe("Undo label reflects number of cut/deleted pages (bug 2010832)", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should show the correct undo label after cutting one or two pages", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
const undoLabelSelector = "#viewsManagerStatusUndoLabel";
// Cut 1 page and check the undo label.
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
let handlePagesEdited = await waitForPagesEdited(page, "cut");
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionCut");
await awaitPromise(handlePagesEdited);
await page.waitForSelector("#viewsManagerStatusUndo", {
visible: true,
});
await waitForTextToBe(page, undoLabelSelector, "1 page cut");
// Undo the cut to restore the original state.
handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, "#viewsManagerStatusUndoButton");
await awaitPromise(handlePagesEdited);
// Cut 2 pages and check the undo label.
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(3)}) input`
);
handlePagesEdited = await waitForPagesEdited(page, "cut");
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionCut");
await awaitPromise(handlePagesEdited);
await page.waitForSelector("#viewsManagerStatusUndo", {
visible: true,
});
await waitForTextToBe(
page,
undoLabelSelector,
`${FSI}2${PDI} pages cut`
);
})
);
});
it("should show the correct undo label after deleting one or two pages", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
const undoLabelSelector = "#viewsManagerStatusUndoLabel";
// Delete 1 page and check the undo label.
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
let handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionDelete");
await awaitPromise(handlePagesEdited);
await page.waitForSelector("#viewsManagerStatusUndo", {
visible: true,
});
await waitForTextToBe(page, undoLabelSelector, "1 page deleted");
// Undo the deletion to restore the original state.
handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, "#viewsManagerStatusUndoButton");
await awaitPromise(handlePagesEdited);
// Delete 2 pages and check the undo label.
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(3)}) input`
);
handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionDelete");
await awaitPromise(handlePagesEdited);
await page.waitForSelector("#viewsManagerStatusUndo", {
visible: true,
});
await waitForTextToBe(
page,
undoLabelSelector,
`${FSI}2${PDI} pages deleted`
);
})
);
});
});
describe("Closing the undo bar after a cut is equivalent to a delete (bug 2010832)", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should permanently remove the cut page when the undo bar is closed", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
// Cut page 1.
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
let handlePagesEdited = await waitForPagesEdited(page, "cut");
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionCut");
await awaitPromise(handlePagesEdited);
await page.waitForSelector("#viewsManagerStatusUndo", {
visible: true,
});
// Close the undo bar instead of undoing.
handlePagesEdited = await waitForPagesEdited(page, "cleanSavedData");
await waitAndClick(page, "#viewsManagerStatusUndoCloseButton");
const pageIndices = await awaitPromise(handlePagesEdited);
// The result must equal a plain deletion of page 1.
const expected = [
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
];
expect(pageIndices)
.withContext(`In ${browserName}`)
.toEqual(expected);
await page.waitForSelector("#viewsManagerStatusUndo", {
hidden: true,
});
await waitForHavingContents(page, expected);
})
);
});
});
describe("Closing the undo bar after a delete effectively deletes the page (bug 2010832)", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should permanently remove the deleted page when the undo bar is closed", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
// Delete page 1.
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
let handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionDelete");
await awaitPromise(handlePagesEdited);
await page.waitForSelector("#viewsManagerStatusUndo", {
visible: true,
});
// Close the undo bar instead of undoing.
handlePagesEdited = await waitForPagesEdited(page, "cleanSavedData");
await waitAndClick(page, "#viewsManagerStatusUndoCloseButton");
const pageIndices = await awaitPromise(handlePagesEdited);
// The page must be effectively deleted.
const expected = [
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
];
expect(pageIndices)
.withContext(`In ${browserName}`)
.toEqual(expected);
await page.waitForSelector("#viewsManagerStatusUndo", {
hidden: true,
});
await waitForHavingContents(page, expected);
})
);
});
});
describe("Clicking Done after copying removes paste buttons (bug 2010832)", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should show a Done button after copy and remove paste buttons when clicked", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
// Copy page 1.
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
const handlePagesEdited = await waitForPagesEdited(page, "copy");
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionCopy");
await awaitPromise(handlePagesEdited);
// The undo bar must appear with a "Done" label (not "Undo").
await page.waitForSelector("#viewsManagerStatusUndo", {
visible: true,
});
await waitForTextToBe(
page,
"#viewsManagerStatusUndoLabel",
"1 page copied"
);
await waitForTextToBe(
page,
"#viewsManagerStatusUndoButton span[data-l10n-id]",
"Done"
);
// The close button must be hidden for copy.
const closeHidden = await page.$eval(
"#viewsManagerStatusUndoCloseButton",
el => el.classList.contains("hidden")
);
expect(closeHidden).withContext(`In ${browserName}`).toBeTrue();
// Paste buttons must be present.
await page.waitForSelector("button.thumbnailPasteButton");
// Click Done and wait for the cancelCopy pagesedited event.
const handleCancelCopy = await waitForPagesEdited(page, "cancelCopy");
await waitAndClick(page, "#viewsManagerStatusUndoButton");
await awaitPromise(handleCancelCopy);
// Undo bar must be hidden and paste buttons must be gone.
await page.waitForSelector("#viewsManagerStatusUndo", {
hidden: true,
});
await page.waitForSelector("button.thumbnailPasteButton", {
hidden: true,
});
const pasteButtons = await page.$$("button.thumbnailPasteButton");
expect(pasteButtons.length).withContext(`In ${browserName}`).toBe(0);
})
);
});
});
describe("Extract some pages from a pdf", () => { describe("Extract some pages from a pdf", () => {
let pages; let pages;

View File

@ -890,6 +890,15 @@ function waitForNoElement(page, selector) {
); );
} }
function waitForTextToBe(page, selector, text) {
return page.waitForFunction(
(sel, str) => document.querySelector(sel)?.textContent.trim() === str,
{},
selector,
text
);
}
function isCanvasMonochrome(page, pageNumber, rectangle, color) { function isCanvasMonochrome(page, pageNumber, rectangle, color) {
return page.evaluate( return page.evaluate(
(rect, pageN, col) => { (rect, pageN, col) => {
@ -1071,6 +1080,7 @@ export {
waitForSelectedEditor, waitForSelectedEditor,
waitForSerialized, waitForSerialized,
waitForStorageEntries, waitForStorageEntries,
waitForTextToBe,
waitForTimeout, waitForTimeout,
waitForUnselectedEditor, waitForUnselectedEditor,
}; };

View File

@ -589,8 +589,9 @@ const PDFViewerApplication = {
pdfScriptingManager.setViewer(pdfViewer); pdfScriptingManager.setViewer(pdfViewer);
if (appConfig.viewsManager?.thumbnailsView) { if (appConfig.viewsManager?.thumbnailsView) {
const { viewsManager } = appConfig;
this.pdfThumbnailViewer = new PDFThumbnailViewer({ this.pdfThumbnailViewer = new PDFThumbnailViewer({
container: appConfig.viewsManager.thumbnailsView, container: viewsManager.thumbnailsView,
eventBus, eventBus,
renderingQueue, renderingQueue,
linkService, linkService,
@ -600,8 +601,10 @@ const PDFViewerApplication = {
abortSignal, abortSignal,
enableHWA, enableHWA,
enableSplitMerge, enableSplitMerge,
manageMenu: appConfig.viewsManager.manageMenu, statusBar: viewsManager.viewsManagerStatusBar,
addFileButton: appConfig.viewsManager.viewsManagerAddFileButton, undoBar: viewsManager.viewsManagerUndoBar,
manageMenu: viewsManager.manageMenu,
addFileButton: viewsManager.viewsManagerAddFileButton,
}); });
renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer); renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer);
} }

View File

@ -424,6 +424,8 @@ class PDFFindController {
#copiedExtractTextPromises = null; #copiedExtractTextPromises = null;
#savedExtractTextPromises = null;
/** /**
* @param {PDFFindControllerOptions} options * @param {PDFFindControllerOptions} options
*/ */
@ -1146,10 +1148,29 @@ class PDFFindController {
return; return;
} }
if (type === "cancelCopy") {
this.#copiedExtractTextPromises = null;
return;
}
if (type === "delete") {
this.#savedExtractTextPromises = this._extractTextPromises;
}
if (type === "cancelDelete") {
this._extractTextPromises = this.#savedExtractTextPromises;
return;
}
if (type === "cleanSavedData") {
this.#savedExtractTextPromises = null;
return;
}
this.#onFindBarClose(); this.#onFindBarClose();
this._dirtyMatch = true; this._dirtyMatch = true;
const prevTextPromises = this._extractTextPromises; const prevTextPromises = this._extractTextPromises;
const extractTextPromises = (this._extractTextPromises.length = []); const extractTextPromises = (this._extractTextPromises = []);
for (let i = 1, ii = pagesMapper.length; i <= ii; i++) { for (let i = 1, ii = pagesMapper.length; i <= ii; i++) {
const prevPageNumber = pagesMapper.getPrevPageNumber(i); const prevPageNumber = pagesMapper.getPrevPageNumber(i);
if (prevPageNumber < 0) { if (prevPageNumber < 0) {

View File

@ -68,6 +68,10 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15;
* rendering. The default value is `false`. * rendering. The default value is `false`.
* @property {boolean} [enableSplitMerge] - Enables split and merge features. * @property {boolean} [enableSplitMerge] - Enables split and merge features.
* The default value is `false`. * The default value is `false`.
* @property {Object} [statusBar] - The status bar elements to manage the status
* label and action when editing pages.
* @property {Object} [undoBar] - The undo bar elements to manage the undo
* action.
* @property {Object} [manageMenu] - The menu elements to manage saving edited * @property {Object} [manageMenu] - The menu elements to manage saving edited
* PDF. * PDF.
* @property {HTMLButtonElement} addFileButton - The button that opens a dialog * @property {HTMLButtonElement} addFileButton - The button that opens a dialog
@ -123,6 +127,10 @@ class PDFThumbnailViewer {
#copiedThumbnails = null; #copiedThumbnails = null;
#savedThumbnails = null;
#deletedPageNumbers = null;
#copiedPageNumbers = null; #copiedPageNumbers = null;
#boundPastePages = this.#pastePages.bind(this); #boundPastePages = this.#pastePages.bind(this);
@ -139,6 +147,18 @@ class PDFThumbnailViewer {
hasSelectedPages: false, hasSelectedPages: false,
}; };
#statusLabel = null;
#statusBar = null;
#undoBar = null;
#undoLabel = null;
#undoButton = null;
#undoCloseButton = null;
/** /**
* @param {PDFThumbnailViewerOptions} options * @param {PDFThumbnailViewerOptions} options
*/ */
@ -153,6 +173,8 @@ class PDFThumbnailViewer {
abortSignal, abortSignal,
enableHWA, enableHWA,
enableSplitMerge, enableSplitMerge,
statusBar,
undoBar,
manageMenu, manageMenu,
addFileButton, addFileButton,
}) { }) {
@ -166,6 +188,13 @@ class PDFThumbnailViewer {
this.pageColors = pageColors || null; this.pageColors = pageColors || null;
this.enableHWA = enableHWA || false; this.enableHWA = enableHWA || false;
this.#enableSplitMerge = enableSplitMerge || false; this.#enableSplitMerge = enableSplitMerge || false;
this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null;
this.#statusBar = statusBar?.viewsManagerStatusAction || null;
this.#undoBar = undoBar?.viewsManagerStatusUndo || null;
this.#undoLabel = undoBar?.viewsManagerStatusUndoLabel || null;
this.#undoButton = undoBar?.viewsManagerStatusUndoButton || null;
this.#undoCloseButton = undoBar?.viewsManagerStatusUndoCloseButton || null;
// TODO: uncomment when the "add file" feature is implemented. // TODO: uncomment when the "add file" feature is implemented.
// this.#addFileButton = addFileButton; // this.#addFileButton = addFileButton;
@ -183,7 +212,7 @@ class PDFThumbnailViewer {
this.#manageSaveAsButton = saveAs; this.#manageSaveAsButton = saveAs;
saveAs.addEventListener("click", this.#saveExtractedPages.bind(this)); saveAs.addEventListener("click", this.#saveExtractedPages.bind(this));
this.#manageDeleteButton = del; this.#manageDeleteButton = del;
del.addEventListener("click", this.#deletePages.bind(this)); del.addEventListener("click", this.#deletePages.bind(this, "delete"));
this.#manageCopyButton = copy; this.#manageCopyButton = copy;
copy.addEventListener("click", this.#copyPages.bind(this)); copy.addEventListener("click", this.#copyPages.bind(this));
this.#manageCutButton = cut; this.#manageCutButton = cut;
@ -201,13 +230,19 @@ class PDFThumbnailViewer {
this.#cutPages(); this.#cutPages();
break; break;
case "deletePage": case "deletePage":
this.#deletePages(); this.#deletePages("delete");
break; break;
case "savePage": case "savePage":
this.#saveExtractedPages(); this.#saveExtractedPages();
break; break;
} }
}); });
this.#undoButton?.addEventListener("click", this.#undo.bind(this));
this.#undoCloseButton?.addEventListener(
"click",
this.#dismissUndo.bind(this)
);
} else { } else {
manageMenu.button.hidden = true; manageMenu.button.hidden = true;
} }
@ -492,17 +527,14 @@ class PDFThumbnailViewer {
#updateThumbnails(currentPageNumber) { #updateThumbnails(currentPageNumber) {
let newCurrentPageNumber = 0; let newCurrentPageNumber = 0;
const pagesMapper = this.#pagesMapper; const pagesMapper = this.#pagesMapper;
this.container.replaceChildren(); const prevThumbnails = (this.#savedThumbnails = this._thumbnails);
const prevThumbnails = this._thumbnails;
const newThumbnails = (this._thumbnails = []); const newThumbnails = (this._thumbnails = []);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const isCut = this.#isCut; const isCut = this.#isCut;
const oldThumbnails = new Set(prevThumbnails);
for (let i = 1, ii = pagesMapper.pagesNumber; i <= ii; i++) { for (let i = 1, ii = pagesMapper.pagesNumber; i <= ii; i++) {
const prevPageNumber = pagesMapper.getPrevPageNumber(i); const prevPageNumber = pagesMapper.getPrevPageNumber(i);
if (prevPageNumber < 0) { if (prevPageNumber < 0) {
let thumbnail = this.#copiedThumbnails.get(-prevPageNumber); let thumbnail = this.#copiedThumbnails.get(-prevPageNumber);
oldThumbnails.delete(thumbnail);
thumbnail.checkbox.checked = false; thumbnail.checkbox.checked = false;
if (isCut) { if (isCut) {
thumbnail.updateId(i); thumbnail.updateId(i);
@ -519,14 +551,10 @@ class PDFThumbnailViewer {
const newThumbnail = prevThumbnails[prevPageNumber - 1]; const newThumbnail = prevThumbnails[prevPageNumber - 1];
newThumbnails.push(newThumbnail); newThumbnails.push(newThumbnail);
newThumbnail.updateId(i); newThumbnail.updateId(i);
oldThumbnails.delete(newThumbnail);
newThumbnail.checkbox.checked = false; newThumbnail.checkbox.checked = false;
fragment.append(newThumbnail.div); fragment.append(newThumbnail.div);
} }
this.container.append(fragment); this.container.replaceChildren(fragment);
for (const oldThumbnail of oldThumbnails) {
oldThumbnail.destroy();
}
return newCurrentPageNumber; return newCurrentPageNumber;
} }
@ -676,6 +704,80 @@ class PDFThumbnailViewer {
}, 0); }, 0);
} }
#undo() {
if (this.#copiedThumbnails) {
// We undo a copy or a cut.
this.#copiedThumbnails = null;
this.#pagesMapper.cancelCopy();
this.#clearSelection();
this.#toggleMenuEntries(false);
this.#updateStatus("select");
this.#togglePasteMode(false);
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper: this.#pagesMapper,
type: "cancelCopy",
});
}
this.#isCut = false;
if (this.#savedThumbnails) {
const fragment = document.createDocumentFragment();
for (let i = 1, ii = this.#savedThumbnails.length; i <= ii; i++) {
const thumbnail = this.#savedThumbnails[i - 1];
thumbnail.updateId(i);
thumbnail.checkbox.checked = false;
fragment.append(thumbnail.div);
}
this.container.replaceChildren(fragment);
this._thumbnails = this.#savedThumbnails;
this.#savedThumbnails = null;
this.#pagesMapper.cancelDelete();
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper: this.#pagesMapper,
type: "cancelDelete",
});
}
}
#dismissUndo() {
this.#copiedThumbnails = null;
if (this.#deletedPageNumbers) {
for (const pageNumber of this.#deletedPageNumbers) {
this.#savedThumbnails[pageNumber - 1].destroy();
}
this.#deletedPageNumbers = null;
this.#savedThumbnails = null;
}
this.#isCut = false;
this.#updateStatus("select");
this.#togglePasteMode(false);
this.#pagesMapper.cleanSavedData();
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper: this.#pagesMapper,
type: "cleanSavedData",
});
}
#togglePasteMode(enable) {
if (enable) {
this.container.classList.add("pasteMode");
for (const thumbnail of this._thumbnails) {
thumbnail.addPasteButton(this.#boundPastePages);
}
} else {
this.container.classList.remove("pasteMode");
for (const thumbnail of this._thumbnails) {
thumbnail.removePasteButton();
}
}
}
#saveExtractedPages() { #saveExtractedPages() {
this.eventBus.dispatch("saveextractedpages", { this.eventBus.dispatch("saveextractedpages", {
source: this, source: this,
@ -686,12 +788,13 @@ class PDFThumbnailViewer {
} }
#copyPages(clearSelection = true) { #copyPages(clearSelection = true) {
this.#updateStatus(this.#isCut ? "cut" : "copy");
const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from( const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from(
this.#selectedPages this.#selectedPages
).sort((a, b) => a - b)); ).sort((a, b) => a - b));
const pagesMapper = this.#pagesMapper; const pagesMapper = this.#pagesMapper;
pagesMapper.copyPages(pageNumbersToCopy); pagesMapper.copyPages(pageNumbersToCopy);
this.#copiedThumbnails ||= new Map(); this.#copiedThumbnails = new Map();
for (const pageNumber of pageNumbersToCopy) { for (const pageNumber of pageNumbersToCopy) {
this.#copiedThumbnails.set(pageNumber, this._thumbnails[pageNumber - 1]); this.#copiedThumbnails.set(pageNumber, this._thumbnails[pageNumber - 1]);
} }
@ -704,10 +807,7 @@ class PDFThumbnailViewer {
if (clearSelection) { if (clearSelection) {
this.#clearSelection(); this.#clearSelection();
} }
for (const thumbnail of this._thumbnails) { this.#togglePasteMode(true);
thumbnail.addPasteButton(this.#boundPastePages);
}
this.container.classList.add("pasteMode");
this.#toggleMenuEntries(false); this.#toggleMenuEntries(false);
} }
@ -718,10 +818,7 @@ class PDFThumbnailViewer {
} }
#pastePages(index) { #pastePages(index) {
this.container.classList.remove("pasteMode"); this.#togglePasteMode(false);
for (const thumbnail of this._thumbnails) {
thumbnail.removePasteButton();
}
this.#toggleMenuEntries(true); this.#toggleMenuEntries(true);
const pagesMapper = this.#pagesMapper; const pagesMapper = this.#pagesMapper;
@ -744,6 +841,7 @@ class PDFThumbnailViewer {
this.#copiedThumbnails = null; this.#copiedThumbnails = null;
this.#isCut = false; this.#isCut = false;
this.#updateMenuEntries(); this.#updateMenuEntries();
this.#updateStatus("select");
this.#updateCurrentPage(currentPageNumber); this.#updateCurrentPage(currentPageNumber);
} }
@ -753,11 +851,16 @@ class PDFThumbnailViewer {
if (selectedPages.size === 0) { if (selectedPages.size === 0) {
return; return;
} }
if (type === "delete") {
this.#updateStatus("delete");
}
const pagesMapper = this.#pagesMapper; const pagesMapper = this.#pagesMapper;
let currentPageNumber = selectedPages.has(this._currentPageNumber) let currentPageNumber = selectedPages.has(this._currentPageNumber)
? 0 ? 0
: this._currentPageNumber; : this._currentPageNumber;
const pagesToDelete = Uint32Array.from(selectedPages).sort((a, b) => a - b); const pagesToDelete = (this.#deletedPageNumbers = Uint32Array.from(
selectedPages
).sort((a, b) => a - b));
pagesMapper.deletePages(pagesToDelete); pagesMapper.deletePages(pagesToDelete);
currentPageNumber = this.#updateThumbnails(currentPageNumber); currentPageNumber = this.#updateThumbnails(currentPageNumber);
@ -793,6 +896,64 @@ class PDFThumbnailViewer {
!enable; !enable;
} }
#updateStatus(type) {
if (!this.#statusBar || !this.#undoBar) {
return;
}
const count = this.#selectedPages?.size || 0;
if (type === "select") {
this.#statusLabel.setAttribute(
"data-l10n-id",
count
? "pdfjs-views-manager-pages-status-action-label"
: "pdfjs-views-manager-pages-status-none-action-label"
);
if (count) {
this.#statusLabel.setAttribute(
"data-l10n-args",
JSON.stringify({ count })
);
} else {
this.#statusLabel.removeAttribute("data-l10n-args");
}
this.#statusBar.classList.toggle("hidden", false);
this.#undoBar.classList.toggle("hidden", true);
return;
}
let l10nId;
switch (type) {
case "copy":
l10nId = "pdfjs-views-manager-pages-status-undo-copy-label";
break;
case "cut":
l10nId = "pdfjs-views-manager-status-undo-cut-label";
break;
case "delete":
l10nId = "pdfjs-views-manager-pages-status-undo-delete-label";
break;
}
this.#undoLabel.setAttribute("data-l10n-id", l10nId);
this.#undoLabel.setAttribute("data-l10n-args", JSON.stringify({ count }));
if (type === "copy") {
this.#undoButton.firstElementChild.setAttribute(
"data-l10n-id",
"pdfjs-views-manager-status-done-button-label"
);
this.#undoCloseButton.classList.toggle("hidden", true);
} else {
this.#undoButton.firstElementChild.setAttribute(
"data-l10n-id",
"pdfjs-views-manager-status-undo-button-label"
);
this.#undoCloseButton.classList.toggle("hidden", false);
}
this.#statusBar.classList.toggle("hidden", true);
this.#undoBar.classList.toggle("hidden", false);
}
#moveDraggedContainer(dx, dy) { #moveDraggedContainer(dx, dy) {
if (this.#isOneColumnView) { if (this.#isOneColumnView) {
dx = 0; dx = 0;
@ -1047,6 +1208,7 @@ class PDFThumbnailViewer {
set.delete(pageNumber); set.delete(pageNumber);
} }
this.#updateMenuEntries(); this.#updateMenuEntries();
this.#updateStatus("select");
} }
#addDragListeners() { #addDragListeners() {

View File

@ -290,6 +290,10 @@ class PDFViewer {
#copiedPageViews = null; #copiedPageViews = null;
#savedPageViews = null;
#deletedPageNumbers = null;
/** /**
* @param {PDFViewerOptions} options * @param {PDFViewerOptions} options
*/ */
@ -1187,11 +1191,42 @@ class PDFViewer {
return; return;
} }
if (type === "cancelCopy") {
this.#copiedPageViews = null;
return;
}
const isCut = type === "cut"; const isCut = type === "cut";
if (isCut || type === "delete") { if (isCut || type === "delete") {
for (const pageNum of pageNumbers) { this.#savedPageViews = this._pages;
this._pages[pageNum - 1].deleteMe(isCut); this.#deletedPageNumbers = pageNumbers;
} }
if (type === "cancelDelete") {
const viewerElement =
this._scrollMode === ScrollMode.PAGE ? null : this.viewer;
if (viewerElement) {
const fragment = document.createDocumentFragment();
for (let i = 0, ii = this.#savedPageViews.length; i < ii; i++) {
const page = this.#savedPageViews[i];
page.updatePageNumber(i + 1);
fragment.append(page.div);
}
viewerElement.replaceChildren(fragment);
}
this._pages = this.#savedPageViews;
this.#savedPageViews = null;
this.#deletedPageNumbers = null;
return;
}
if (type === "cleanSavedData") {
for (const pageNum of this.#deletedPageNumbers) {
this.#savedPageViews[pageNum - 1].deleteMe();
}
this.#savedPageViews = null;
this.#deletedPageNumbers = null;
return;
} }
this._currentPageNumber = 0; this._currentPageNumber = 0;
@ -1221,14 +1256,11 @@ class PDFViewer {
const viewerElement = const viewerElement =
this._scrollMode === ScrollMode.PAGE ? null : this.viewer; this._scrollMode === ScrollMode.PAGE ? null : this.viewer;
if (viewerElement) { if (viewerElement) {
viewerElement.replaceChildren();
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) { for (const { div } of newPages) {
const { div } = newPages[i];
div.setAttribute("data-page-number", i + 1);
fragment.append(div); fragment.append(div);
} }
viewerElement.append(fragment); viewerElement.replaceChildren(fragment);
} }
setTimeout(() => { setTimeout(() => {

View File

@ -230,7 +230,7 @@ See https://github.com/adobe-type-tools/cmap-resources
</div> </div>
</div> </div>
<div id="viewsManagerStatusUndo" class="hidden"> <div id="viewsManagerStatusUndo" class="hidden">
<span class="viewsManagerStatusLabel" data-l10n-id="pdfjs-views-manager-status-undo-cut-label" data-l10n-args='{"count": 0}'></span> <span id="viewsManagerStatusUndoLabel" class="viewsManagerStatusLabel"></span>
<div> <div>
<button id="viewsManagerStatusUndoButton" class="viewsManagerButton" type="button" tabindex="0"> <button id="viewsManagerStatusUndoButton" class="viewsManagerButton" type="button" tabindex="0">
<span data-l10n-id="pdfjs-views-manager-status-undo-button-label"></span> <span data-l10n-id="pdfjs-views-manager-status-undo-button-label"></span>
@ -247,7 +247,7 @@ See https://github.com/adobe-type-tools/cmap-resources
</div> </div>
</div> </div>
<div id="viewsManagerStatusWarning" class="hidden"> <div id="viewsManagerStatusWarning" class="hidden">
<span class="viewsManagerStatusLabel"></span> <span id="viewsManagerStatusWarningLabel" class="viewsManagerStatusLabel"></span>
<button <button
id="viewsManagerStatusWarningCloseButton" id="viewsManagerStatusWarningCloseButton"
class="toolbarButton viewsManagerButton viewsCloseButton" class="toolbarButton viewsManagerButton viewsCloseButton"
@ -259,7 +259,7 @@ See https://github.com/adobe-type-tools/cmap-resources
</button> </button>
</div> </div>
<div id="viewsManagerStatusWaiting" class="hidden"> <div id="viewsManagerStatusWaiting" class="hidden">
<span class="viewsManagerStatusLabel"></span> <span id="viewsManagerStatusWaitingLabel" class="viewsManagerStatusLabel"></span>
<button <button
id="viewsManagerStatusWaitingCloseButton" id="viewsManagerStatusWaitingCloseButton"
class="toolbarButton viewsManagerButton viewsCloseButton" class="toolbarButton viewsManagerButton viewsCloseButton"

View File

@ -134,6 +134,28 @@ function getViewerConfiguration() {
"viewsManagerHeaderLabel" "viewsManagerHeaderLabel"
), ),
viewsManagerStatus: document.getElementById("viewsManagerStatus"), viewsManagerStatus: document.getElementById("viewsManagerStatus"),
viewsManagerStatusBar: {
viewsManagerStatusAction: document.getElementById(
"viewsManagerStatusAction"
),
viewsManagerStatusActionLabel: document.getElementById(
"viewsManagerStatusActionLabel"
),
},
viewsManagerUndoBar: {
viewsManagerStatusUndo: document.getElementById(
"viewsManagerStatusUndo"
),
viewsManagerStatusUndoLabel: document.getElementById(
"viewsManagerStatusUndoLabel"
),
viewsManagerStatusUndoButton: document.getElementById(
"viewsManagerStatusUndoButton"
),
viewsManagerStatusUndoCloseButton: document.getElementById(
"viewsManagerStatusUndoCloseButton"
),
},
manageMenu: { manageMenu: {
button: document.getElementById("viewsManagerStatusActionButton"), button: document.getElementById("viewsManagerStatusActionButton"),
menu: document.getElementById("viewsManagerStatusActionOptions"), menu: document.getElementById("viewsManagerStatusActionOptions"),

View File

@ -226,10 +226,11 @@
} }
&.viewsCloseButton { &.viewsCloseButton {
width: 32px; width: 26px;
height: 32px; height: 26px;
padding: 4px; padding: 4px;
border-radius: 8px; border-radius: 8px;
background: none;
&::before { &::before {
mask-image: var(--close-button-icon); mask-image: var(--close-button-icon);
@ -402,7 +403,7 @@
align-self: stretch; align-self: stretch;
background-color: var(--status-actions-bg); background-color: var(--status-actions-bg);
> span.selected::before { > span[data-l10n-args]::before {
content: ""; content: "";
display: inline-block; display: inline-block;
width: var(--icon-size); width: var(--icon-size);