Add the UI for merging PDFs (bug 2028071)

This commit is contained in:
Calixte Denizet 2026-04-02 19:34:04 +02:00
parent 96debf0c81
commit 8c9b819b4e
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
13 changed files with 496 additions and 98 deletions

View File

@ -783,3 +783,5 @@ pdfjs-views-manager-paste-button-after =
# Badge used to promote a new feature in the UI, keep it as short as possible. # Badge used to promote a new feature in the UI, keep it as short as possible.
# It's spelled uppercase for English, but it can be translated as usual. # It's spelled uppercase for English, but it can be translated as usual.
pdfjs-new-badge-content = NEW pdfjs-new-badge-content = NEW
pdfjs-views-manager-waiting-for-file = Uploading file…

View File

@ -560,8 +560,138 @@ class PDFEditor {
* excluded ranges (inclusive) or indices. * excluded ranges (inclusive) or indices.
* @property {Array<number>} [pageIndices] * @property {Array<number>} [pageIndices]
* position of the pages in the final document. * position of the pages in the final document.
* @property {number} [insertAfter]
* 0-based index in the base sequential document after which to insert the
* pages. Sequential pageInfos (those without pageIndices) have their indices
* shifted to accommodate the insertion. Cannot be combined with pageIndices.
*/ */
/**
* Return the document-local page indices that pass the include/exclude
* filters for the given pageInfo, in document order.
* @param {PageInfo} pageInfo
* @returns {Array<number>}
*/
#getFilteredPageIndices({ document, includePages, excludePages }) {
if (!document) {
return [];
}
let keptIndices, keptRanges, deletedIndices, deletedRanges;
for (const page of includePages || []) {
if (Array.isArray(page)) {
(keptRanges ||= []).push(page);
} else {
(keptIndices ||= new Set()).add(page);
}
}
for (const page of excludePages || []) {
if (Array.isArray(page)) {
(deletedRanges ||= []).push(page);
} else {
(deletedIndices ||= new Set()).add(page);
}
}
const indices = [];
for (let i = 0, ii = document.numPages; i < ii; i++) {
if (deletedIndices?.has(i)) {
continue;
}
if (deletedRanges) {
let isDeleted = false;
for (const [start, end] of deletedRanges) {
if (i >= start && i <= end) {
isDeleted = true;
break;
}
}
if (isDeleted) {
continue;
}
}
let takePage = false;
if (keptIndices) {
takePage = keptIndices.has(i);
}
if (!takePage && keptRanges) {
for (const [start, end] of keptRanges) {
if (i >= start && i <= end) {
takePage = true;
break;
}
}
}
if (!takePage && !keptIndices && !keptRanges) {
takePage = true;
}
if (takePage) {
indices.push(i);
}
}
return indices;
}
/**
* Resolve insertAfter pageInfos by converting them (and sequential pageInfos)
* to explicit pageIndices, shifting indices to accommodate each insertion.
* insertAfter values are relative to the base sequential sequence (i.e. the
* concatenation of pages from pageInfos that have neither pageIndices nor
* insertAfter), so they are independent of each other.
* @param {Array<PageInfo>} pageInfos
* @returns {Array<PageInfo>}
*/
#resolveInsertAfterIndices(pageInfos) {
// Single pass: build the base sequential sequence and collect insertAfter
// entries, computing each pageInfo's filtered page count only once and only
// for pageInfos that actually contribute pages.
const sequence = []; // each element is the index into pageInfos
const insertAfterList = [];
for (let i = 0; i < pageInfos.length; i++) {
const info = pageInfos[i];
if (!info.document || info.pageIndices) {
continue;
}
const count = this.#getFilteredPageIndices(info).length;
if (info.insertAfter === undefined) {
for (let j = 0; j < count; j++) {
sequence.push(i);
}
} else {
insertAfterList.push({ i, insertAfter: info.insertAfter, count });
}
}
// Sort by insertAfter value so that each value is interpreted relative to
// the same base sequential sequence, then insert into the sequence.
// The offset accumulates the number of pages already inserted, converting
// base-relative positions to current-sequence positions.
insertAfterList.sort((a, b) => a.insertAfter - b.insertAfter);
let offset = 0;
for (const { i, insertAfter, count } of insertAfterList) {
const insertPos = insertAfter + 1 + offset;
sequence.splice(insertPos, 0, ...new Array(count).fill(i));
offset += count;
}
// Map each pageInfo index to its final positions in the sequence using a
// plain array (keys are dense integers so no need for a Map).
const pageIndicesArr = new Array(pageInfos.length);
for (let pos = 0; pos < sequence.length; pos++) {
const infoIdx = sequence[pos];
(pageIndicesArr[infoIdx] ||= []).push(pos);
}
// Return updated pageInfos: sequential and insertAfter pageInfos now have
// explicit pageIndices; already-indexed pageInfos are left unchanged.
return pageInfos.map((info, i) => {
if (!info.document || info.pageIndices) {
return info;
}
const newInfo = { ...info, pageIndices: pageIndicesArr[i] || [] };
delete newInfo.insertAfter;
return newInfo;
});
}
/** /**
* Extract pages from the given documents. * Extract pages from the given documents.
* @param {Array<PageInfo>} pageInfos * @param {Array<PageInfo>} pageInfos
@ -574,6 +704,9 @@ class PDFEditor {
* @return {Promise<void>} * @return {Promise<void>}
*/ */
async extractPages(pageInfos, annotationStorage, handler, task) { async extractPages(pageInfos, annotationStorage, handler, task) {
if (pageInfos.some(info => info.insertAfter !== undefined)) {
pageInfos = this.#resolveInsertAfterIndices(pageInfos);
}
const promises = []; const promises = [];
let newIndex = 0; let newIndex = 0;
this.isSingleFile = this.isSingleFile =
@ -610,57 +743,12 @@ class PDFEditor {
const documentData = new DocumentData(document); const documentData = new DocumentData(document);
allDocumentData.push(documentData); allDocumentData.push(documentData);
promises.push(this.#collectDocumentData(documentData)); promises.push(this.#collectDocumentData(documentData));
let keptIndices, keptRanges, deletedIndices, deletedRanges;
for (const page of includePages || []) {
if (Array.isArray(page)) {
(keptRanges ||= []).push(page);
} else {
(keptIndices ||= new Set()).add(page);
}
}
for (const page of excludePages || []) {
if (Array.isArray(page)) {
(deletedRanges ||= []).push(page);
} else {
(deletedIndices ||= new Set()).add(page);
}
}
let pageIndex = 0; let pageIndex = 0;
for (let i = 0, ii = document.numPages; i < ii; i++) { for (const i of this.#getFilteredPageIndices({
if (deletedIndices?.has(i)) { document,
continue; includePages,
} excludePages,
if (deletedRanges) { })) {
let isDeleted = false;
for (const [start, end] of deletedRanges) {
if (i >= start && i <= end) {
isDeleted = true;
break;
}
}
if (isDeleted) {
continue;
}
}
let takePage = false;
if (keptIndices) {
takePage = keptIndices.has(i);
}
if (!takePage && keptRanges) {
for (const [start, end] of keptRanges) {
if (i >= start && i <= end) {
takePage = true;
break;
}
}
}
if (!takePage && !keptIndices && !keptRanges) {
takePage = true;
}
if (!takePage) {
continue;
}
let newPageIndex; let newPageIndex;
if (pageIndices) { if (pageIndices) {
newPageIndex = pageIndices[pageIndex++]; newPageIndex = pageIndices[pageIndex++];

View File

@ -40,6 +40,9 @@ import {
waitForTextToBe, waitForTextToBe,
waitForTooltipToBe, waitForTooltipToBe,
} from "./test_utils.mjs"; } from "./test_utils.mjs";
import path from "path";
const __dirname = import.meta.dirname;
async function waitForThumbnailVisible(page, pageNums) { async function waitForThumbnailVisible(page, pageNums) {
await showViewsManager(page); await showViewsManager(page);
@ -3030,4 +3033,68 @@ describe("Reorganize Pages View", () => {
); );
}); });
}); });
describe("Merge PDF", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"three_pages_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true, enableMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should merge a PDF after the current page", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
// Navigate to page 2 so the merged PDF is inserted after it.
await page.evaluate(() => {
window.PDFViewerApplication.page = 2;
});
await page.waitForFunction(
() => window.PDFViewerApplication.page === 2
);
await waitAndClick(page, getThumbnailSelector(2));
const handleMerged = await createPromise(page, resolve => {
window.PDFViewerApplication.eventBus._on(
"thumbnailsloaded",
resolve,
{ once: true }
);
});
const picker = await page.$("#viewsManagerAddFilePicker");
await picker.uploadFile(
path.join(__dirname, "../pdfs/three_pages_with_number.pdf")
);
await awaitPromise(handleMerged);
// Original 3 pages + 3 merged pages = 6 pages total.
await page.waitForFunction(
() => parseInt(document.getElementById("pageNumber").max, 10) === 6
);
// Pages 12 come from the original document, then all 3 pages of
// the merged PDF, then pages 46 of the original shifted to the end.
await waitForHavingContents(page, [1, 2, 1, 2, 3, 3]);
await waitForTextToBe(
page,
"#viewsManagerStatusActionLabel",
`${FSI}3${PDI} selected`
);
})
);
});
});
}); });

View File

@ -901,3 +901,4 @@
!issue21068.pdf !issue21068.pdf
!recursiveCompositGlyf.pdf !recursiveCompositGlyf.pdf
!issue19634.pdf !issue19634.pdf
!three_pages_with_number.pdf

Binary file not shown.

View File

@ -7018,6 +7018,116 @@ small scripts as well as for`);
expect(newPdfDoc.numPages).toEqual(2); expect(newPdfDoc.numPages).toEqual(2);
await passwordAcceptedLoadingTask.destroy(); await passwordAcceptedLoadingTask.destroy();
}); });
it("insertAfter places pages at the given position", async function () {
// page_with_number.pdf has 17 pages; text on page N (1-based) is "N".
// Sequential pageInfo contributes pages 1 and 3 (0-based) at base
// positions 0 and 1. The insertAfter pageInfo inserts page 2 after
// base position 0, so the final order should be: "1" · "2" · "3".
let loadingTask = getDocument(
buildGetDocumentParams("page_with_number.pdf")
);
let pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null, includePages: [0, 2] },
{ document: null, includePages: [1], insertAfter: 0 },
]);
await loadingTask.destroy();
loadingTask = getDocument(data);
pdfDoc = await loadingTask.promise;
expect(pdfDoc.numPages).toEqual(3);
for (const [pageNum, expected] of [
[1, "1"],
[2, "2"],
[3, "3"],
]) {
const pdfPage = await pdfDoc.getPage(pageNum);
const { items } = await pdfPage.getTextContent();
expect(mergeText(items))
.withContext(`Page ${pageNum}`)
.toEqual(expected);
}
await loadingTask.destroy();
});
it("insertAfter shifts sequential pageInfos across multiple entries", async function () {
// Two separate sequential pageInfos (pages 1 and 3, 0-based) form
// the base sequence at positions 0 and 1. Page 2 is inserted after
// base position 0, so both sequential entries should be shifted and
// the final order should be: "1" · "2" · "3".
let loadingTask = getDocument(
buildGetDocumentParams("page_with_number.pdf")
);
let pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null, includePages: [0] },
{ document: null, includePages: [2] },
{ document: null, includePages: [1], insertAfter: 0 },
]);
await loadingTask.destroy();
loadingTask = getDocument(data);
pdfDoc = await loadingTask.promise;
expect(pdfDoc.numPages).toEqual(3);
for (const [pageNum, expected] of [
[1, "1"],
[2, "2"],
[3, "3"],
]) {
const pdfPage = await pdfDoc.getPage(pageNum);
const { items } = await pdfPage.getTextContent();
expect(mergeText(items))
.withContext(`Page ${pageNum}`)
.toEqual(expected);
}
await loadingTask.destroy();
});
it("insertAfter without includePages inserts all pages", async function () {
// Sequential pageInfo uses pages 05 ("1""6", base positions 05).
// The insertAfter pageInfo has no includePages so all 17 pages are
// inserted after base position 4, landing between "5" and "6".
// Final order: "1"·"2"·"3"·"4"·"5" · "1"…"17" · "6" = 23 pages.
let loadingTask = getDocument(
buildGetDocumentParams("page_with_number.pdf")
);
let pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null, includePages: [0, 1, 2, 3, 4, 5] },
{ document: null, insertAfter: 4 },
]);
await loadingTask.destroy();
loadingTask = getDocument(data);
pdfDoc = await loadingTask.promise;
expect(pdfDoc.numPages).toEqual(23);
// Last page of the first sequential chunk.
let pdfPage = await pdfDoc.getPage(5);
let { items } = await pdfPage.getTextContent();
expect(mergeText(items)).withContext("Page 5").toEqual("5");
// First and last of the 17 inserted pages.
pdfPage = await pdfDoc.getPage(6);
({ items } = await pdfPage.getTextContent());
expect(mergeText(items)).withContext("Page 6").toEqual("1");
pdfPage = await pdfDoc.getPage(22);
({ items } = await pdfPage.getTextContent());
expect(mergeText(items)).withContext("Page 22").toEqual("17");
// Sequential page "6" shifted to the end.
pdfPage = await pdfDoc.getPage(23);
({ items } = await pdfPage.getTextContent());
expect(mergeText(items)).withContext("Page 23").toEqual("6");
await loadingTask.destroy();
});
}); });
}); });
}); });

View File

@ -375,6 +375,7 @@ const PDFViewerApplication = {
enableGuessAltText: x => x === "true", enableGuessAltText: x => x === "true",
enableNewBadge: x => x === "true", enableNewBadge: x => x === "true",
enablePermissions: x => x === "true", enablePermissions: x => x === "true",
enableMerge: x => x === "true",
enableSplitMerge: x => x === "true", enableSplitMerge: x => x === "true",
enableUpdatedAddImage: x => x === "true", enableUpdatedAddImage: x => x === "true",
highlightEditorColors: x => x, highlightEditorColors: x => x,
@ -461,6 +462,7 @@ const PDFViewerApplication = {
foreground: AppOptions.get("pageColorsForeground"), foreground: AppOptions.get("pageColorsForeground"),
} }
: null; : null;
const enableMerge = AppOptions.get("enableMerge");
const enableSplitMerge = AppOptions.get("enableSplitMerge"); const enableSplitMerge = AppOptions.get("enableSplitMerge");
let altTextManager; let altTextManager;
@ -601,11 +603,13 @@ const PDFViewerApplication = {
pageColors, pageColors,
abortSignal, abortSignal,
enableSplitMerge, enableSplitMerge,
enableMerge,
enableNewBadge: AppOptions.get("enableNewBadge"), enableNewBadge: AppOptions.get("enableNewBadge"),
statusBar: viewsManager.viewsManagerStatusBar, statusBar: viewsManager.viewsManagerStatusBar,
undoBar: viewsManager.viewsManagerUndoBar, undoBar: viewsManager.viewsManagerUndoBar,
manageMenu: viewsManager.manageMenu, manageMenu: viewsManager.manageMenu,
addFileButton: viewsManager.viewsManagerAddFileButton, waitingBar: viewsManager.viewsManagerWaitingBar,
addFileComponent: viewsManager.viewsManagerAddFile,
}); });
renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer); renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer);
} }
@ -765,6 +769,7 @@ const PDFViewerApplication = {
elements: appConfig.viewsManager, elements: appConfig.viewsManager,
eventBus, eventBus,
l10n, l10n,
enableMerge,
enableSplitMerge, enableSplitMerge,
globalAbortSignal: abortSignal, globalAbortSignal: abortSignal,
}); });
@ -2217,6 +2222,7 @@ const PDFViewerApplication = {
} }
eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts); eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts);
eventBus._on("saveextractedpages", this.onSavePages.bind(this), opts); eventBus._on("saveextractedpages", this.onSavePages.bind(this), opts);
eventBus._on("saveandload", this.onSaveAndLoad.bind(this), opts);
}, },
bindWindowEvents() { bindWindowEvents() {
@ -2419,6 +2425,23 @@ const PDFViewerApplication = {
); );
}, },
async onSaveAndLoad({ data: extractParams }) {
if (!this.pdfDocument) {
return;
}
const modifiedPdfBytes = await this.pdfDocument.extractPages(extractParams);
if (!modifiedPdfBytes) {
console.error(
"Something wrong happened when saving the edited PDF.\nPlease file a bug."
);
return;
}
this.open({
data: modifiedPdfBytes,
filename: this._docFilename,
});
},
_accumulateTicks(ticks, prop) { _accumulateTicks(ticks, prop) {
// If the direction changed, reset the accumulated ticks. // If the direction changed, reset the accumulated ticks.
if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) { if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) {

View File

@ -232,6 +232,11 @@ const defaultOptions = {
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE, kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
}, },
enableMerge: {
/** @type {boolean} */
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
enableNewAltTextWhenAddingImage: { enableNewAltTextWhenAddingImage: {
/** @type {boolean} */ /** @type {boolean} */
value: true, value: true,

View File

@ -66,6 +66,8 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15;
* events. * events.
* @property {boolean} [enableNewBadge] - Enables the "new" badge for the split * @property {boolean} [enableNewBadge] - Enables the "new" badge for the split
* and merge features. * and merge features.
* @property {boolean} [enableMerge] - Enables the merge feature.
* 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 * @property {Object} [statusBar] - The status bar elements to manage the status
@ -74,8 +76,11 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15;
* action. * 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 {Object} [waitingBar] - The waiting bar elements shown during
* to add a PDF file to merge with the current one. * long-running operations.
* @property {Object} [addFileComponent] - The file picker and button used to
* add a PDF file to merge with the current one.
*/
/** /**
* Viewer control to display thumbnails for pages in a PDF document. * Viewer control to display thumbnails for pages in a PDF document.
@ -83,6 +88,8 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15;
class PDFThumbnailViewer { class PDFThumbnailViewer {
static #draggingScaleFactor = 0; static #draggingScaleFactor = 0;
#enableMerge = false;
#enableSplitMerge = false; #enableSplitMerge = false;
#dragAC = null; #dragAC = null;
@ -157,6 +164,8 @@ class PDFThumbnailViewer {
#undoCloseButton = null; #undoCloseButton = null;
#waitingBar = null;
#isInPasteMode = false; #isInPasteMode = false;
#hasUndoBarVisible = false; #hasUndoBarVisible = false;
@ -175,12 +184,14 @@ class PDFThumbnailViewer {
maxCanvasDim, maxCanvasDim,
pageColors, pageColors,
abortSignal, abortSignal,
enableMerge,
enableSplitMerge, enableSplitMerge,
enableNewBadge, enableNewBadge,
statusBar, statusBar,
undoBar, undoBar,
waitingBar,
manageMenu, manageMenu,
addFileButton, addFileComponent,
}) { }) {
this.scrollableContainer = container.parentElement; this.scrollableContainer = container.parentElement;
this.container = container; this.container = container;
@ -190,6 +201,7 @@ class PDFThumbnailViewer {
this.maxCanvasPixels = maxCanvasPixels; this.maxCanvasPixels = maxCanvasPixels;
this.maxCanvasDim = maxCanvasDim; this.maxCanvasDim = maxCanvasDim;
this.pageColors = pageColors || null; this.pageColors = pageColors || null;
this.#enableMerge = enableMerge || false;
this.#enableSplitMerge = enableSplitMerge || false; this.#enableSplitMerge = enableSplitMerge || false;
this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null; this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null;
this.#deselectButton = this.#deselectButton =
@ -199,13 +211,11 @@ class PDFThumbnailViewer {
this.#undoLabel = undoBar?.viewsManagerStatusUndoLabel || null; this.#undoLabel = undoBar?.viewsManagerStatusUndoLabel || null;
this.#undoButton = undoBar?.viewsManagerStatusUndoButton || null; this.#undoButton = undoBar?.viewsManagerStatusUndoButton || null;
this.#undoCloseButton = undoBar?.viewsManagerStatusUndoCloseButton || null; this.#undoCloseButton = undoBar?.viewsManagerStatusUndoCloseButton || null;
this.#waitingBar = waitingBar || null;
// TODO: uncomment when the "add file" feature is implemented.
// this.#addFileButton = addFileButton;
if (this.#enableSplitMerge && manageMenu) { if (this.#enableSplitMerge && manageMenu) {
const { const {
button, button: menuButton,
menu, menu,
copy, copy,
cut, cut,
@ -217,19 +227,19 @@ class PDFThumbnailViewer {
const newSpan = document.createElement("span"); const newSpan = document.createElement("span");
newSpan.setAttribute("data-l10n-id", "pdfjs-new-badge-content"); newSpan.setAttribute("data-l10n-id", "pdfjs-new-badge-content");
newSpan.classList.add("newBadge"); newSpan.classList.add("newBadge");
button.parentElement.before(newSpan); menuButton.parentElement.before(newSpan);
this.#newBadge = newSpan; this.#newBadge = newSpan;
} }
this.eventBus.on( this.eventBus.on(
"pagesloaded", "pagesloaded",
() => { () => {
button.disabled = false; menuButton.disabled = false;
}, },
{ once: true } { once: true }
); );
this._manageMenu = new Menu(menu, button, [ this._manageMenu = new Menu(menu, menuButton, [
copy, copy,
cut, cut,
del, del,
@ -248,7 +258,7 @@ class PDFThumbnailViewer {
cut.addEventListener("click", this.#cutPages.bind(this)); cut.addEventListener("click", this.#cutPages.bind(this));
this.#toggleMenuEntries(false); this.#toggleMenuEntries(false);
button.disabled = true; menuButton.disabled = true;
this.eventBus.on("editingaction", ({ name }) => { this.eventBus.on("editingaction", ({ name }) => {
switch (name) { switch (name) {
@ -301,6 +311,63 @@ class PDFThumbnailViewer {
this.#updateStatus("select"); this.#updateStatus("select");
}); });
this.#deselectButton.classList.toggle("hidden", true); this.#deselectButton.classList.toggle("hidden", true);
if (this.#enableMerge && addFileComponent) {
const { picker, button } = addFileComponent;
picker.addEventListener("change", async () => {
const file = picker.files?.[0];
if (!file) {
return;
}
if (file.type !== "application/pdf") {
const magic = await file.slice(0, 5).text();
if (magic !== "%PDF-") {
return;
}
}
this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file");
const currentPageIndex = this._currentPageNumber - 1;
const buffer = await file.bytes();
const pagesCount = this.#pagesMapper.pagesNumber;
const data = this.hasStructuralChanges()
? this.getStructuralChanges()
: [{ document: null }];
data.push({
document: buffer,
insertAfter: currentPageIndex ?? -1,
});
this.eventBus._on(
"thumbnailsloaded",
() => {
this.#toggleBar("status");
const newPagesCount = this.#pagesMapper.pagesNumber;
const insertedPagesCount = newPagesCount - pagesCount;
for (
let i = currentPageIndex + 1,
ii = currentPageIndex + 1 + insertedPagesCount;
i < ii;
i++
) {
this._thumbnails[i].checkbox.checked = true;
this.#selectPage(i + 1, true);
}
},
{ once: true }
);
this.#reportTelemetry({ action: "merge" });
this.eventBus.dispatch("saveandload", {
source: this,
data,
});
});
button.addEventListener("click", () => {
picker.click();
});
this.#waitingBar.closeButton?.addEventListener("click", () => {
this.#toggleBar("status");
picker.value = "";
});
}
} else { } else {
manageMenu.button.hidden = true; manageMenu.button.hidden = true;
} }
@ -466,6 +533,9 @@ class PDFThumbnailViewer {
const thumbnailView = this._thumbnails[this._currentPageNumber - 1]; const thumbnailView = this._thumbnails[this._currentPageNumber - 1];
thumbnailView.toggleCurrent(/* isCurrent = */ true); thumbnailView.toggleCurrent(/* isCurrent = */ true);
this.container.append(fragment); this.container.append(fragment);
this.eventBus.dispatch("thumbnailsloaded", {
source: this,
});
}) })
.catch(reason => { .catch(reason => {
console.error("Unable to initialize thumbnail viewer", reason); console.error("Unable to initialize thumbnail viewer", reason);
@ -830,6 +900,37 @@ class PDFThumbnailViewer {
return size > 0 && size < this._thumbnails.length; return size > 0 && size < this._thumbnails.length;
} }
#toggleBar(type, message, args) {
this.#statusBar.classList.toggle("hidden", type !== "status");
this.#waitingBar.container.classList.toggle("hidden", type !== "waiting");
this.#undoBar.classList.toggle("hidden", type !== "undo");
this.#hasUndoBarVisible = type === "undo";
switch (type) {
case "waiting":
this.#waitingBar.label.setAttribute("data-l10n-id", message);
break;
case "undo":
this.#undoLabel.setAttribute("data-l10n-id", message);
if (args) {
this.#undoLabel.setAttribute("data-l10n-args", JSON.stringify(args));
}
break;
case "status":
if (args) {
this.#statusLabel.setAttribute(
"data-l10n-args",
JSON.stringify(args)
);
} else {
this.#statusLabel.removeAttribute("data-l10n-args");
}
this.#newBadge?.classList.toggle("hidden", !!args);
this.#deselectButton.classList.toggle("hidden", !args);
break;
}
}
#togglePasteMode(enable) { #togglePasteMode(enable) {
this.#isInPasteMode = enable; this.#isInPasteMode = enable;
if (enable) { if (enable) {
@ -996,21 +1097,7 @@ class PDFThumbnailViewer {
? "pdfjs-views-manager-pages-status-action-label" ? "pdfjs-views-manager-pages-status-action-label"
: "pdfjs-views-manager-pages-status-none-action-label" : "pdfjs-views-manager-pages-status-none-action-label"
); );
if (count) { this.#toggleBar("status", "", count ? { count } : null);
this.#newBadge?.classList.add("hidden");
this.#statusLabel.setAttribute(
"data-l10n-args",
JSON.stringify({ count })
);
this.#deselectButton.classList.toggle("hidden", false);
} else {
this.#newBadge?.classList.remove("hidden");
this.#statusLabel.removeAttribute("data-l10n-args");
this.#deselectButton.classList.toggle("hidden", true);
}
this.#statusBar.classList.toggle("hidden", false);
this.#undoBar.classList.toggle("hidden", true);
this.#hasUndoBarVisible = false;
return; return;
} }
@ -1026,8 +1113,7 @@ class PDFThumbnailViewer {
l10nId = "pdfjs-views-manager-pages-status-undo-delete-label"; l10nId = "pdfjs-views-manager-pages-status-undo-delete-label";
break; break;
} }
this.#undoLabel.setAttribute("data-l10n-id", l10nId); this.#toggleBar("undo", l10nId, { count });
this.#undoLabel.setAttribute("data-l10n-args", JSON.stringify({ count }));
if (type === "copy") { if (type === "copy") {
this.#undoButton.firstElementChild.setAttribute( this.#undoButton.firstElementChild.setAttribute(
@ -1042,10 +1128,6 @@ class PDFThumbnailViewer {
); );
this.#undoCloseButton.classList.toggle("hidden", false); this.#undoCloseButton.classList.toggle("hidden", false);
} }
this.#statusBar.classList.toggle("hidden", true);
this.#undoBar.classList.toggle("hidden", false);
this.#hasUndoBarVisible = true;
} }
#moveDraggedContainer(dx, dy) { #moveDraggedContainer(dx, dy) {

View File

@ -162,6 +162,7 @@ See https://github.com/adobe-type-tools/cmap-resources
hidden="true" hidden="true"
> >
<span data-l10n-id="pdfjs-views-manager-add-file-button-label"></span> <span data-l10n-id="pdfjs-views-manager-add-file-button-label"></span>
<input id="viewsManagerAddFilePicker" type="file" accept="application/pdf" />
</button> </button>
<button <button
id="viewsManagerCurrentOutlineButton" id="viewsManagerCurrentOutlineButton"

View File

@ -124,9 +124,10 @@ function getViewerConfiguration() {
outlinesView: document.getElementById("outlinesView"), outlinesView: document.getElementById("outlinesView"),
attachmentsView: document.getElementById("attachmentsView"), attachmentsView: document.getElementById("attachmentsView"),
layersView: document.getElementById("layersView"), layersView: document.getElementById("layersView"),
viewsManagerAddFileButton: document.getElementById( viewsManagerAddFile: {
"viewsManagerAddFileButton" button: document.getElementById("viewsManagerAddFileButton"),
), picker: document.getElementById("viewsManagerAddFilePicker"),
},
viewsManagerCurrentOutlineButton: document.getElementById( viewsManagerCurrentOutlineButton: document.getElementById(
"viewsManagerCurrentOutlineButton" "viewsManagerCurrentOutlineButton"
), ),
@ -159,6 +160,13 @@ function getViewerConfiguration() {
"viewsManagerStatusUndoCloseButton" "viewsManagerStatusUndoCloseButton"
), ),
}, },
viewsManagerWaitingBar: {
container: document.getElementById("viewsManagerStatusWaiting"),
closeButton: document.getElementById(
"viewsManagerStatusWaitingCloseButton"
),
label: document.getElementById("viewsManagerStatusWaitingLabel"),
},
manageMenu: { manageMenu: {
button: document.getElementById("viewsManagerStatusActionButton"), button: document.getElementById("viewsManagerStatusActionButton"),
menu: document.getElementById("viewsManagerStatusActionOptions"), menu: document.getElementById("viewsManagerStatusActionOptions"),

View File

@ -355,8 +355,6 @@
} }
#viewsManagerAddFileButton { #viewsManagerAddFileButton {
visibility: hidden;
background: var(--button-no-bg); background: var(--button-no-bg);
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -369,6 +367,10 @@
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-image: var(--views-manager-add-file-button-icon); mask-image: var(--views-manager-add-file-button-icon);
} }
> input {
display: none;
}
} }
#viewsManagerCurrentOutlineButton { #viewsManagerCurrentOutlineButton {
@ -388,17 +390,21 @@
} }
#viewsManagerStatus { #viewsManagerStatus {
display: flex; display: grid;
align-items: center; width: 100%;
align-self: stretch;
justify-content: space-between;
width: auto;
border: 1px solid var(--status-border-color); border: 1px solid var(--status-border-color);
> div { > div {
min-height: 64px; min-height: 64px;
width: 100%; width: 100%;
padding-inline: 16px; padding-inline: 16px;
grid-area: 1 / 1;
box-sizing: border-box;
&.hidden {
visibility: hidden;
display: unset !important;
}
} }
.viewsManagerStatusLabel { .viewsManagerStatusLabel {

View File

@ -89,7 +89,7 @@ class ViewsManager extends Sidebar {
outlinesView, outlinesView,
attachmentsView, attachmentsView,
layersView, layersView,
viewsManagerAddFileButton, viewsManagerAddFile: { button: viewsManagerAddFileButton },
viewsManagerCurrentOutlineButton, viewsManagerCurrentOutlineButton,
viewsManagerSelectorButton, viewsManagerSelectorButton,
viewsManagerSelectorOptions, viewsManagerSelectorOptions,
@ -98,6 +98,7 @@ class ViewsManager extends Sidebar {
}, },
eventBus, eventBus,
l10n, l10n,
enableMerge = false,
enableSplitMerge = false, enableSplitMerge = false,
globalAbortSignal, globalAbortSignal,
}) { }) {
@ -149,6 +150,10 @@ class ViewsManager extends Sidebar {
viewsManagerStatus.hidden = true; viewsManagerStatus.hidden = true;
} }
this._enableSplitMerge = enableSplitMerge; this._enableSplitMerge = enableSplitMerge;
this._enableMerge = enableMerge;
if (!enableMerge) {
viewsManagerAddFileButton.hidden = true;
}
this.menu = new Menu( this.menu = new Menu(
viewsManagerSelectorOptions, viewsManagerSelectorOptions,
@ -260,10 +265,10 @@ class ViewsManager extends Sidebar {
return; return;
} }
if (this._enableSplitMerge) { this.viewsManagerStatus.hidden =
this.viewsManagerStatus.hidden = view !== SidebarView.THUMBS; !this._enableSplitMerge || view !== SidebarView.THUMBS;
} this.viewsManagerAddFileButton.hidden =
this.viewsManagerAddFileButton.hidden = view !== SidebarView.THUMBS; !this._enableMerge || view !== SidebarView.THUMBS;
this.viewsManagerCurrentOutlineButton.hidden = view !== SidebarView.OUTLINE; this.viewsManagerCurrentOutlineButton.hidden = view !== SidebarView.OUTLINE;
this.viewsManagerHeaderLabel.setAttribute( this.viewsManagerHeaderLabel.setAttribute(
"data-l10n-id", "data-l10n-id",