mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-05-31 07:11:00 +02:00
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:
parent
b13ec1fc3c
commit
d79043b3af
@ -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`
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user