Allow merging a PDF by dropping it onto the thumbnail viewer

Drop an external PDF anywhere in the views-manager thumbnail
sidebar to merge it at the cursor, rather than always inserting
after the current page via the "Add file" button.

The drop reuses the blue separator from page-move drag so the
user can see exactly where the inserted pages will land, and the
merge path is shared with the existing picker so post-merge
selection/current-page behavior stays consistent.
This commit is contained in:
Calixte Denizet 2026-05-20 14:28:54 +02:00
parent b13ec1fc3c
commit d79043b3af
3 changed files with 365 additions and 67 deletions

View File

@ -40,10 +40,27 @@ import {
waitForTextToBe,
waitForTooltipToBe,
} from "./test_utils.mjs";
import fs from "fs";
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 waitForThumbnailVisible(page, pageNums) {
await showViewsManager(page);
@ -3227,4 +3244,136 @@ describe("Reorganize Pages View", () => {
);
});
});
describe("Drag-and-drop PDF merge", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"three_pages_with_number.pdf",
'.page[data-page-number = "1"] .endOfContent',
"1",
null,
{ enableSplitMerge: true, enableMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should show the marker and merge before the first thumbnail", 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"
);
const markerInfo = await page.evaluate(
(transfer, selector) => {
const container = document.getElementById("thumbnailsView");
const target = document.querySelector(selector);
const { left, top, width, height } =
target.getBoundingClientRect();
const clientX = left + width / 4;
const clientY = top + height / 4;
const dispatchDragEvent = type => {
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
clientX,
clientY,
dataTransfer: transfer,
})
);
};
dispatchDragEvent("dragenter");
dispatchDragEvent("dragover");
const marker = container.querySelector(":scope > .dragMarker");
const { width: markerWidth = 0, height: markerHeight = 0 } =
marker?.getBoundingClientRect() ?? {};
const translate = marker?.style.translate ?? "";
const filesLength = transfer.files.length;
dispatchDragEvent("dragleave");
const survivedDragLeave = !!container.querySelector(
":scope > .dragMarker"
);
return {
markerHeight,
markerWidth,
filesLength,
survivedDragLeave,
translate,
};
},
dataTransfer,
getThumbnailSelector(1)
);
expect(markerInfo.markerWidth + markerInfo.markerHeight)
.withContext(`In ${browserName}, marker dimensions`)
.toBeGreaterThan(0);
expect(markerInfo.filesLength)
.withContext(`In ${browserName}, dropped files`)
.toBe(1);
expect(markerInfo.translate.includes("NaN"))
.withContext(`In ${browserName}, marker position`)
.toBeFalse();
expect(markerInfo.survivedDragLeave)
.withContext(`In ${browserName}, marker after child dragleave`)
.toBeTrue();
const handleMerged = await createPromise(page, resolve => {
const listener = ({ pagesCount }) => {
if (pagesCount !== 6) {
return;
}
window.PDFViewerApplication.eventBus.off("pagesloaded", listener);
resolve();
};
window.PDFViewerApplication.eventBus.on("pagesloaded", listener);
});
await page.evaluate(
(transfer, selector) => {
const target = document.querySelector(selector);
const { left, top, width, height } =
target.getBoundingClientRect();
target.dispatchEvent(
new DragEvent("drop", {
bubbles: true,
cancelable: true,
clientX: left + width / 4,
clientY: top + height / 4,
dataTransfer: transfer,
})
);
},
dataTransfer,
getThumbnailSelector(1)
);
await awaitPromise(handleMerged);
await page.waitForFunction(
() => parseInt(document.getElementById("pageNumber").max, 10) === 6
);
await page.waitForFunction(
() => window.PDFViewerApplication.page === 1
);
await waitForHavingContents(page, [1, 2, 3, 1, 2, 3]);
await waitForTextToBe(
page,
"#viewsManagerStatusActionLabel",
`${FSI}3${PDI} selected`
);
})
);
});
});
});

View File

@ -94,6 +94,10 @@ class PDFThumbnailViewer {
#dragAC = null;
#abortSignal = undefined;
#externalDragActive = false;
#draggedContainer = null;
#thumbnailsPositions = null;
@ -201,6 +205,7 @@ class PDFThumbnailViewer {
this.maxCanvasPixels = maxCanvasPixels;
this.maxCanvasDim = maxCanvasDim;
this.pageColors = pageColors || null;
this.#abortSignal = abortSignal;
this.#enableMerge = enableMerge || false;
this.#enableSplitMerge = enableSplitMerge || false;
this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null;
@ -315,62 +320,11 @@ class PDFThumbnailViewer {
if (this.#enableMerge && addFileComponent) {
const { picker, button } = addFileComponent;
picker.addEventListener("change", async () => {
picker.addEventListener("change", () => {
const file = picker.files?.[0];
if (!file) {
return;
if (file) {
this.#mergeFile(file, this._currentPageNumber - 1);
}
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(
"pagesloaded",
() => {
// Clear any pre-merge selection: thumbnails are rebuilt fresh
// (all unchecked), so the old set would cause a label/visual
// mismatch.
this.#selectedPages = null;
this.#updateMenuEntries();
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);
}
if (insertedPagesCount) {
this.#updateCurrentPage(
currentPageIndex + 2,
/* force = */ true
);
}
},
{ once: true }
);
this.#reportTelemetry({ action: "merge" });
this.eventBus.dispatch("saveandload", {
source: this,
data,
});
});
button.addEventListener("click", () => {
picker.click();
@ -397,6 +351,55 @@ 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;
}
}
this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file");
const buffer = await file.bytes();
const pagesCount = this.#pagesMapper.pagesNumber;
const data = this.hasStructuralChanges()
? this.getStructuralChanges()
: [{ document: null }];
data.push({
document: buffer,
insertAfter,
});
this.eventBus._on(
"pagesloaded",
() => {
// Clear any pre-merge selection: thumbnails are rebuilt fresh
// (all unchecked), so the old set would cause a label/visual
// mismatch.
this.#selectedPages = null;
this.#updateMenuEntries();
this.#toggleBar("status");
const newPagesCount = this.#pagesMapper.pagesNumber;
const insertedPagesCount = newPagesCount - pagesCount;
for (
let i = insertAfter + 1, ii = insertAfter + 1 + insertedPagesCount;
i < ii;
i++
) {
this._thumbnails[i].checkbox.checked = true;
this.#selectPage(i + 1, true);
}
if (insertedPagesCount) {
this.#updateCurrentPage(insertAfter + 2, /* force = */ true);
}
},
{ once: true }
);
this.#reportTelemetry({ action: "merge" });
this.eventBus.dispatch("saveandload", {
source: this,
data,
});
}
getThumbnail(index) {
return this._thumbnails[index];
}
@ -1185,6 +1188,10 @@ class PDFThumbnailViewer {
this.#draggedImageX + this.#draggedImageWidth / 2,
this.#draggedImageY + this.#draggedImageHeight / 2
);
this.#positionDragMarker(positionData);
}
#positionDragMarker(positionData) {
if (!positionData) {
return;
}
@ -1202,7 +1209,7 @@ class PDFThumbnailViewer {
if (index < 0) {
if (xPos.length === 1) {
y = bbox[1] - SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT;
x = bbox[4];
x = bbox[0];
width = bbox[2];
} else {
y = bbox[1];
@ -1272,16 +1279,22 @@ class PDFThumbnailViewer {
lastRightX ??= cx + w;
}
}
const space =
positionsX.length > 1
? (positionsX[1] - firstRightX) / 2
: (positionsY[1] - firstBottomY) / 2;
let space;
if (positionsX.length > 1) {
space = (positionsX[1] - firstRightX) / 2;
} else if (positionsY.length > 1) {
space = (positionsY[1] - firstBottomY) / 2;
} else {
space = SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT;
}
this.#thumbnailsPositions = {
x: positionsX,
y: positionsY,
lastX: positionsLastX,
space,
lastSpace: (positionsLastX.at(-1) - lastRightX) / 2,
lastSpace: positionsLastX.length
? (positionsLastX.at(-1) - lastRightX) / 2
: space,
bbox,
};
this.#isOneColumnView = positionsX.length === 1;
@ -1380,6 +1393,7 @@ class PDFThumbnailViewer {
this.#goToPage(e);
});
this.#addDragListeners();
this.#addExternalFileDropListeners();
}
#selectPage(pageNumber, checked) {
@ -1550,6 +1564,140 @@ class PDFThumbnailViewer {
});
}
#addExternalFileDropListeners() {
if (!this.#enableMerge) {
return;
}
const container = this.container;
const signal = this.#abortSignal;
const hasPdfItem = dataTransfer => {
if (!dataTransfer) {
return false;
}
// The file's bytes aren't readable during dragover, so the MIME type is
// 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.
for (const item of dataTransfer.items) {
if (item.kind === "file" && item.type === "application/pdf") {
return true;
}
}
return false;
};
const pointerInContainer = ({ clientX, clientY }) => {
const { left, right, top, bottom } = container.getBoundingClientRect();
return (
clientX >= left && clientX < right && clientY >= top && clientY < bottom
);
};
container.addEventListener(
"dragenter",
e => {
if (
this.#externalDragActive ||
// A page-move drag is already in progress.
!isNaN(this.#lastDraggedOverIndex) ||
!this._thumbnails.length ||
!hasPdfItem(e.dataTransfer)
) {
return;
}
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
this.#externalDragActive = true;
this.container.classList.add("isDraggingFile");
// Recompute positions in case the layout changed since last time.
this.#thumbnailsPositions = null;
this.#computeThumbnailsPosition();
// Marker hasn't been positioned yet — first dragover will do it.
this.#lastDraggedOverIndex = NaN;
},
{ signal }
);
container.addEventListener(
"dragover",
e => {
if (!this.#externalDragActive) {
return;
}
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
if (!this.#thumbnailsPositions) {
return;
}
const rect = container.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const positionData = this.#findClosestThumbnail(x, y);
this.#positionDragMarker(positionData);
},
{ signal }
);
container.addEventListener(
"dragleave",
e => {
if (!this.#externalDragActive) {
return;
}
// dragleave fires when crossing into a child element too; only treat
// it as a true leave when the cursor has actually left the container.
if (
(e.relatedTarget && container.contains(e.relatedTarget)) ||
pointerInContainer(e)
) {
return;
}
this.#endExternalFileDrag();
},
{ signal }
);
container.addEventListener(
"drop",
e => {
if (!this.#externalDragActive) {
return;
}
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0];
// 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) {
const rect = container.getBoundingClientRect();
this.#findClosestThumbnail(
e.clientX - rect.left,
e.clientY - rect.top
);
}
const insertAfter = isNaN(this.#lastDraggedOverIndex)
? -1
: this.#lastDraggedOverIndex;
this.#endExternalFileDrag();
if (file) {
this.#mergeFile(file, insertAfter);
}
},
{ signal }
);
}
#endExternalFileDrag() {
this.#externalDragActive = false;
this.container.classList.remove("isDraggingFile");
this.#dragMarker?.remove();
this.#dragMarker = null;
this.#lastDraggedOverIndex = NaN;
}
#goToPage(e) {
const container = e.target.closest(".thumbnailImageContainer");
if (container) {

View File

@ -632,14 +632,15 @@
pointer-events: none;
}
}
}
> .dragMarker {
position: absolute;
top: 0;
left: 0;
border: 2px solid var(--indicator-color);
contain: strict;
}
&.isDragging > .dragMarker,
&.isDraggingFile > .dragMarker {
position: absolute;
top: 0;
left: 0;
border: 2px solid var(--indicator-color);
contain: strict;
}
&.pasteMode {