diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs
index f5bdb6412..3622826f0 100644
--- a/test/integration/reorganize_pages_spec.mjs
+++ b/test/integration/reorganize_pages_spec.mjs
@@ -45,20 +45,24 @@ import path from "path";
const __dirname = import.meta.dirname;
-async function createPDFDataTransfer(page, filename) {
- const pdfPath = path.join(__dirname, "../pdfs", filename);
- const pdfData = fs.readFileSync(pdfPath).toString("base64");
- return page.evaluateHandle(
- (data, name) => {
- const transfer = new DataTransfer();
- const view = Uint8Array.fromBase64(data);
- const file = new File([view], name, { type: "application/pdf" });
- transfer.items.add(file);
- return transfer;
- },
- pdfData,
- filename
- );
+async function createPDFDataTransfer(page, ...filenames) {
+ const pdfData = filenames.map(filename => {
+ const pdfPath = path.join(__dirname, "../pdfs", filename);
+ return {
+ data: fs.readFileSync(pdfPath).toString("base64"),
+ filename,
+ };
+ });
+ return page.evaluateHandle(data => {
+ const transfer = new DataTransfer();
+ for (const { data: base64, filename } of data) {
+ const view = Uint8Array.fromBase64(base64);
+ transfer.items.add(
+ new File([view], filename, { type: "application/pdf" })
+ );
+ }
+ return transfer;
+ }, pdfData);
}
async function waitForThumbnailVisible(page, pageNums) {
@@ -3243,6 +3247,62 @@ describe("Reorganize Pages View", () => {
})
);
});
+
+ it("should merge several PDFs selected at once", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ await waitForThumbnailVisible(page, 1);
+
+ // Navigate to page 2 so the merged PDFs are 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");
+ const pdfPath = path.join(
+ __dirname,
+ "../pdfs/three_pages_with_number.pdf"
+ );
+ // Upload two PDFs in a single picker selection.
+ await picker.uploadFile(pdfPath, pdfPath);
+ await awaitPromise(handleMerged);
+
+ // Original 3 pages + 2 * 3 merged pages = 9 pages total.
+ await page.waitForFunction(
+ () => parseInt(document.getElementById("pageNumber").max, 10) === 9
+ );
+
+ // Focus must move to the first newly inserted page (page 3, since
+ // we merged after page 2).
+ await page.waitForFunction(
+ () => window.PDFViewerApplication.page === 3
+ );
+
+ // Pages 1–2 of the original, then both merged copies (in selection
+ // order), then page 3 of the original shifted to the end.
+ await waitForHavingContents(page, [1, 2, 1, 2, 3, 1, 2, 3, 3]);
+
+ // All 6 newly inserted pages must be selected.
+ await waitForTextToBe(
+ page,
+ "#viewsManagerStatusActionLabel",
+ `${FSI}6${PDI} selected`
+ );
+ })
+ );
+ });
});
describe("Drag-and-drop PDF merge", () => {
@@ -3375,5 +3435,65 @@ describe("Reorganize Pages View", () => {
})
);
});
+
+ it("should merge several dropped PDFs at once", async () => {
+ await Promise.all(
+ pages.map(async ([browserName, page]) => {
+ await waitForThumbnailVisible(page, [1, 2, 3]);
+
+ const dataTransfer = await createPDFDataTransfer(
+ page,
+ "three_pages_with_number.pdf",
+ "three_pages_with_number.pdf"
+ );
+
+ const handleMerged = await createPromise(page, resolve => {
+ window.PDFViewerApplication.eventBus.on(
+ "thumbnailsloaded",
+ resolve,
+ { once: true }
+ );
+ });
+ const filesLength = await page.evaluate(
+ (transfer, selector) => {
+ const target = document.querySelector(selector);
+ const { left, top, width, height } =
+ target.getBoundingClientRect();
+ const clientX = left + width / 4;
+ const clientY = top + (3 * height) / 4;
+ for (const type of ["dragenter", "dragover", "drop"]) {
+ target.dispatchEvent(
+ new DragEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ clientX,
+ clientY,
+ dataTransfer: transfer,
+ })
+ );
+ }
+ return transfer.files.length;
+ },
+ dataTransfer,
+ getThumbnailSelector(2)
+ );
+ expect(filesLength).withContext(`In ${browserName}`).toBe(2);
+ await awaitPromise(handleMerged);
+
+ await page.waitForFunction(
+ () => parseInt(document.getElementById("pageNumber").max, 10) === 9
+ );
+ await page.waitForFunction(
+ () => window.PDFViewerApplication.page === 3
+ );
+ await waitForHavingContents(page, [1, 2, 1, 2, 3, 1, 2, 3, 3]);
+ await waitForTextToBe(
+ page,
+ "#viewsManagerStatusActionLabel",
+ `${FSI}6${PDI} selected`
+ );
+ })
+ );
+ });
});
});
diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js
index 443c199be..9e0c3e18d 100644
--- a/web/pdf_thumbnail_viewer.js
+++ b/web/pdf_thumbnail_viewer.js
@@ -79,7 +79,7 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15;
* @property {Object} [waitingBar] - The waiting bar elements shown during
* long-running operations.
* @property {Object} [addFileComponent] - The file picker and button used to
- * add a PDF file to merge with the current one.
+ * add one or more PDF files to merge with the current one.
*/
/**
@@ -321,9 +321,9 @@ class PDFThumbnailViewer {
if (this.#enableMerge && addFileComponent) {
const { picker, button } = addFileComponent;
picker.addEventListener("change", () => {
- const file = picker.files?.[0];
- if (file) {
- this.#mergeFile(file, this._currentPageNumber - 1);
+ const files = Array.from(picker.files ?? []);
+ if (files.length) {
+ this.#mergeFiles(files, this._currentPageNumber - 1);
}
});
button.addEventListener("click", () => {
@@ -351,23 +351,32 @@ class PDFThumbnailViewer {
this.renderingQueue.renderHighestPriority();
}
- async #mergeFile(file, insertAfter) {
- if (file.type !== "application/pdf") {
- const magic = await file.slice(0, 5).text();
- if (magic !== "%PDF-") {
- return;
- }
- }
+ async #mergeFiles(files, insertAfter) {
this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file");
- const buffer = await file.bytes();
+ const buffers = [];
+ for (const file of files) {
+ if (file.type !== "application/pdf") {
+ const magic = await file.slice(0, 5).text();
+ if (magic !== "%PDF-") {
+ continue;
+ }
+ }
+ buffers.push(await file.bytes());
+ }
+ if (buffers.length === 0) {
+ this.#toggleBar("status");
+ return;
+ }
const pagesCount = this.#pagesMapper.pagesNumber;
const data = this.hasStructuralChanges()
? this.getStructuralChanges()
: [{ document: null }];
- data.push({
- document: buffer,
- insertAfter,
- });
+ for (const buffer of buffers) {
+ data.push({
+ document: buffer,
+ insertAfter,
+ });
+ }
this.eventBus._on(
"pagesloaded",
() => {
@@ -1579,7 +1588,7 @@ class PDFThumbnailViewer {
// the only available signal. Matches the existing global drop handler
// in app.js. Files with no MIME (e.g. some macOS sources) are rejected
// here to keep the "copy" cursor honest; if needed, drop-time magic-byte
- // validation in #mergeFile would still catch a permissive variant.
+ // validation in #mergeFiles would still catch a permissive variant.
for (const item of dataTransfer.items) {
if (item.kind === "file" && item.type === "application/pdf") {
return true;
@@ -1668,7 +1677,7 @@ class PDFThumbnailViewer {
}
e.preventDefault();
e.stopPropagation();
- const file = e.dataTransfer.files?.[0];
+ const files = Array.from(e.dataTransfer.files ?? []);
// If no dragover ever ran (e.g. instant drop), compute the index from
// the drop event itself so we don't fall through to a stale fallback.
if (isNaN(this.#lastDraggedOverIndex) && this.#thumbnailsPositions) {
@@ -1682,8 +1691,8 @@ class PDFThumbnailViewer {
? -1
: this.#lastDraggedOverIndex;
this.#endExternalFileDrag();
- if (file) {
- this.#mergeFile(file, insertAfter);
+ if (files.length) {
+ this.#mergeFiles(files, insertAfter);
}
},
{ signal }
diff --git a/web/viewer.html b/web/viewer.html
index e4d24d7e4..6ac7f7cc7 100644
--- a/web/viewer.html
+++ b/web/viewer.html
@@ -191,7 +191,7 @@ See https://github.com/adobe-type-tools/cmap-resources
hidden="true"
>
-
+