mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-04-18 03:04:07 +02:00
Merge pull request #21060 from calixteman/implement_merge
Add the UI for merging PDFs (bug 2028071)
This commit is contained in:
commit
b82ceda22b
@ -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.
|
||||
# It's spelled uppercase for English, but it can be translated as usual.
|
||||
pdfjs-new-badge-content = NEW
|
||||
|
||||
pdfjs-views-manager-waiting-for-file = Uploading file…
|
||||
|
||||
@ -560,8 +560,138 @@ class PDFEditor {
|
||||
* excluded ranges (inclusive) or indices.
|
||||
* @property {Array<number>} [pageIndices]
|
||||
* 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.
|
||||
* @param {Array<PageInfo>} pageInfos
|
||||
@ -574,6 +704,9 @@ class PDFEditor {
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async extractPages(pageInfos, annotationStorage, handler, task) {
|
||||
if (pageInfos.some(info => info.insertAfter !== undefined)) {
|
||||
pageInfos = this.#resolveInsertAfterIndices(pageInfos);
|
||||
}
|
||||
const promises = [];
|
||||
let newIndex = 0;
|
||||
this.isSingleFile =
|
||||
@ -610,57 +743,12 @@ class PDFEditor {
|
||||
const documentData = new DocumentData(document);
|
||||
allDocumentData.push(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;
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
for (const i of this.#getFilteredPageIndices({
|
||||
document,
|
||||
includePages,
|
||||
excludePages,
|
||||
})) {
|
||||
let newPageIndex;
|
||||
if (pageIndices) {
|
||||
newPageIndex = pageIndices[pageIndex++];
|
||||
|
||||
@ -40,6 +40,9 @@ import {
|
||||
waitForTextToBe,
|
||||
waitForTooltipToBe,
|
||||
} from "./test_utils.mjs";
|
||||
import path from "path";
|
||||
|
||||
const __dirname = import.meta.dirname;
|
||||
|
||||
async function waitForThumbnailVisible(page, pageNums) {
|
||||
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 1–2 come from the original document, then all 3 pages of
|
||||
// the merged PDF, then pages 4–6 of the original shifted to the end.
|
||||
await waitForHavingContents(page, [1, 2, 1, 2, 3, 3]);
|
||||
|
||||
await waitForTextToBe(
|
||||
page,
|
||||
"#viewsManagerStatusActionLabel",
|
||||
`${FSI}3${PDI} selected`
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1
test/pdfs/.gitignore
vendored
1
test/pdfs/.gitignore
vendored
@ -901,3 +901,4 @@
|
||||
!issue21068.pdf
|
||||
!recursiveCompositGlyf.pdf
|
||||
!issue19634.pdf
|
||||
!three_pages_with_number.pdf
|
||||
|
||||
BIN
test/pdfs/three_pages_with_number.pdf
Executable file
BIN
test/pdfs/three_pages_with_number.pdf
Executable file
Binary file not shown.
@ -7018,6 +7018,116 @@ small scripts as well as for`);
|
||||
expect(newPdfDoc.numPages).toEqual(2);
|
||||
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 0–5 ("1"–"6", base positions 0–5).
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
25
web/app.js
25
web/app.js
@ -375,6 +375,7 @@ const PDFViewerApplication = {
|
||||
enableGuessAltText: x => x === "true",
|
||||
enableNewBadge: x => x === "true",
|
||||
enablePermissions: x => x === "true",
|
||||
enableMerge: x => x === "true",
|
||||
enableSplitMerge: x => x === "true",
|
||||
enableUpdatedAddImage: x => x === "true",
|
||||
highlightEditorColors: x => x,
|
||||
@ -461,6 +462,7 @@ const PDFViewerApplication = {
|
||||
foreground: AppOptions.get("pageColorsForeground"),
|
||||
}
|
||||
: null;
|
||||
const enableMerge = AppOptions.get("enableMerge");
|
||||
const enableSplitMerge = AppOptions.get("enableSplitMerge");
|
||||
|
||||
let altTextManager;
|
||||
@ -601,11 +603,13 @@ const PDFViewerApplication = {
|
||||
pageColors,
|
||||
abortSignal,
|
||||
enableSplitMerge,
|
||||
enableMerge,
|
||||
enableNewBadge: AppOptions.get("enableNewBadge"),
|
||||
statusBar: viewsManager.viewsManagerStatusBar,
|
||||
undoBar: viewsManager.viewsManagerUndoBar,
|
||||
manageMenu: viewsManager.manageMenu,
|
||||
addFileButton: viewsManager.viewsManagerAddFileButton,
|
||||
waitingBar: viewsManager.viewsManagerWaitingBar,
|
||||
addFileComponent: viewsManager.viewsManagerAddFile,
|
||||
});
|
||||
renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer);
|
||||
}
|
||||
@ -765,6 +769,7 @@ const PDFViewerApplication = {
|
||||
elements: appConfig.viewsManager,
|
||||
eventBus,
|
||||
l10n,
|
||||
enableMerge,
|
||||
enableSplitMerge,
|
||||
globalAbortSignal: abortSignal,
|
||||
});
|
||||
@ -2217,6 +2222,7 @@ const PDFViewerApplication = {
|
||||
}
|
||||
eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts);
|
||||
eventBus._on("saveextractedpages", this.onSavePages.bind(this), opts);
|
||||
eventBus._on("saveandload", this.onSaveAndLoad.bind(this), opts);
|
||||
},
|
||||
|
||||
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) {
|
||||
// If the direction changed, reset the accumulated ticks.
|
||||
if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) {
|
||||
|
||||
@ -232,6 +232,11 @@ const defaultOptions = {
|
||||
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||
},
|
||||
enableMerge: {
|
||||
/** @type {boolean} */
|
||||
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||
},
|
||||
enableNewAltTextWhenAddingImage: {
|
||||
/** @type {boolean} */
|
||||
value: true,
|
||||
|
||||
@ -66,6 +66,8 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15;
|
||||
* events.
|
||||
* @property {boolean} [enableNewBadge] - Enables the "new" badge for the split
|
||||
* and merge features.
|
||||
* @property {boolean} [enableMerge] - Enables the merge feature.
|
||||
* The default value is `false`.
|
||||
* @property {boolean} [enableSplitMerge] - Enables split and merge features.
|
||||
* The default value is `false`.
|
||||
* @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.
|
||||
* @property {Object} [manageMenu] - The menu elements to manage saving edited
|
||||
* PDF.
|
||||
* @property {HTMLButtonElement} addFileButton - The button that opens a dialog
|
||||
* to add a PDF file to merge with the current one.
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
static #draggingScaleFactor = 0;
|
||||
|
||||
#enableMerge = false;
|
||||
|
||||
#enableSplitMerge = false;
|
||||
|
||||
#dragAC = null;
|
||||
@ -157,6 +164,8 @@ class PDFThumbnailViewer {
|
||||
|
||||
#undoCloseButton = null;
|
||||
|
||||
#waitingBar = null;
|
||||
|
||||
#isInPasteMode = false;
|
||||
|
||||
#hasUndoBarVisible = false;
|
||||
@ -175,12 +184,14 @@ class PDFThumbnailViewer {
|
||||
maxCanvasDim,
|
||||
pageColors,
|
||||
abortSignal,
|
||||
enableMerge,
|
||||
enableSplitMerge,
|
||||
enableNewBadge,
|
||||
statusBar,
|
||||
undoBar,
|
||||
waitingBar,
|
||||
manageMenu,
|
||||
addFileButton,
|
||||
addFileComponent,
|
||||
}) {
|
||||
this.scrollableContainer = container.parentElement;
|
||||
this.container = container;
|
||||
@ -190,6 +201,7 @@ class PDFThumbnailViewer {
|
||||
this.maxCanvasPixels = maxCanvasPixels;
|
||||
this.maxCanvasDim = maxCanvasDim;
|
||||
this.pageColors = pageColors || null;
|
||||
this.#enableMerge = enableMerge || false;
|
||||
this.#enableSplitMerge = enableSplitMerge || false;
|
||||
this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null;
|
||||
this.#deselectButton =
|
||||
@ -199,13 +211,11 @@ class PDFThumbnailViewer {
|
||||
this.#undoLabel = undoBar?.viewsManagerStatusUndoLabel || null;
|
||||
this.#undoButton = undoBar?.viewsManagerStatusUndoButton || null;
|
||||
this.#undoCloseButton = undoBar?.viewsManagerStatusUndoCloseButton || null;
|
||||
|
||||
// TODO: uncomment when the "add file" feature is implemented.
|
||||
// this.#addFileButton = addFileButton;
|
||||
this.#waitingBar = waitingBar || null;
|
||||
|
||||
if (this.#enableSplitMerge && manageMenu) {
|
||||
const {
|
||||
button,
|
||||
button: menuButton,
|
||||
menu,
|
||||
copy,
|
||||
cut,
|
||||
@ -217,19 +227,19 @@ class PDFThumbnailViewer {
|
||||
const newSpan = document.createElement("span");
|
||||
newSpan.setAttribute("data-l10n-id", "pdfjs-new-badge-content");
|
||||
newSpan.classList.add("newBadge");
|
||||
button.parentElement.before(newSpan);
|
||||
menuButton.parentElement.before(newSpan);
|
||||
this.#newBadge = newSpan;
|
||||
}
|
||||
|
||||
this.eventBus.on(
|
||||
"pagesloaded",
|
||||
() => {
|
||||
button.disabled = false;
|
||||
menuButton.disabled = false;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
this._manageMenu = new Menu(menu, button, [
|
||||
this._manageMenu = new Menu(menu, menuButton, [
|
||||
copy,
|
||||
cut,
|
||||
del,
|
||||
@ -248,7 +258,7 @@ class PDFThumbnailViewer {
|
||||
cut.addEventListener("click", this.#cutPages.bind(this));
|
||||
|
||||
this.#toggleMenuEntries(false);
|
||||
button.disabled = true;
|
||||
menuButton.disabled = true;
|
||||
|
||||
this.eventBus.on("editingaction", ({ name }) => {
|
||||
switch (name) {
|
||||
@ -301,6 +311,63 @@ class PDFThumbnailViewer {
|
||||
this.#updateStatus("select");
|
||||
});
|
||||
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 {
|
||||
manageMenu.button.hidden = true;
|
||||
}
|
||||
@ -466,6 +533,9 @@ class PDFThumbnailViewer {
|
||||
const thumbnailView = this._thumbnails[this._currentPageNumber - 1];
|
||||
thumbnailView.toggleCurrent(/* isCurrent = */ true);
|
||||
this.container.append(fragment);
|
||||
this.eventBus.dispatch("thumbnailsloaded", {
|
||||
source: this,
|
||||
});
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error("Unable to initialize thumbnail viewer", reason);
|
||||
@ -830,6 +900,37 @@ class PDFThumbnailViewer {
|
||||
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) {
|
||||
this.#isInPasteMode = enable;
|
||||
if (enable) {
|
||||
@ -996,21 +1097,7 @@ class PDFThumbnailViewer {
|
||||
? "pdfjs-views-manager-pages-status-action-label"
|
||||
: "pdfjs-views-manager-pages-status-none-action-label"
|
||||
);
|
||||
if (count) {
|
||||
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;
|
||||
this.#toggleBar("status", "", count ? { count } : null);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1026,8 +1113,7 @@ class PDFThumbnailViewer {
|
||||
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 }));
|
||||
this.#toggleBar("undo", l10nId, { count });
|
||||
|
||||
if (type === "copy") {
|
||||
this.#undoButton.firstElementChild.setAttribute(
|
||||
@ -1042,10 +1128,6 @@ class PDFThumbnailViewer {
|
||||
);
|
||||
this.#undoCloseButton.classList.toggle("hidden", false);
|
||||
}
|
||||
|
||||
this.#statusBar.classList.toggle("hidden", true);
|
||||
this.#undoBar.classList.toggle("hidden", false);
|
||||
this.#hasUndoBarVisible = true;
|
||||
}
|
||||
|
||||
#moveDraggedContainer(dx, dy) {
|
||||
|
||||
@ -162,6 +162,7 @@ See https://github.com/adobe-type-tools/cmap-resources
|
||||
hidden="true"
|
||||
>
|
||||
<span data-l10n-id="pdfjs-views-manager-add-file-button-label"></span>
|
||||
<input id="viewsManagerAddFilePicker" type="file" accept="application/pdf" />
|
||||
</button>
|
||||
<button
|
||||
id="viewsManagerCurrentOutlineButton"
|
||||
|
||||
@ -124,9 +124,10 @@ function getViewerConfiguration() {
|
||||
outlinesView: document.getElementById("outlinesView"),
|
||||
attachmentsView: document.getElementById("attachmentsView"),
|
||||
layersView: document.getElementById("layersView"),
|
||||
viewsManagerAddFileButton: document.getElementById(
|
||||
"viewsManagerAddFileButton"
|
||||
),
|
||||
viewsManagerAddFile: {
|
||||
button: document.getElementById("viewsManagerAddFileButton"),
|
||||
picker: document.getElementById("viewsManagerAddFilePicker"),
|
||||
},
|
||||
viewsManagerCurrentOutlineButton: document.getElementById(
|
||||
"viewsManagerCurrentOutlineButton"
|
||||
),
|
||||
@ -159,6 +160,13 @@ function getViewerConfiguration() {
|
||||
"viewsManagerStatusUndoCloseButton"
|
||||
),
|
||||
},
|
||||
viewsManagerWaitingBar: {
|
||||
container: document.getElementById("viewsManagerStatusWaiting"),
|
||||
closeButton: document.getElementById(
|
||||
"viewsManagerStatusWaitingCloseButton"
|
||||
),
|
||||
label: document.getElementById("viewsManagerStatusWaitingLabel"),
|
||||
},
|
||||
manageMenu: {
|
||||
button: document.getElementById("viewsManagerStatusActionButton"),
|
||||
menu: document.getElementById("viewsManagerStatusActionOptions"),
|
||||
|
||||
@ -355,8 +355,6 @@
|
||||
}
|
||||
|
||||
#viewsManagerAddFileButton {
|
||||
visibility: hidden;
|
||||
|
||||
background: var(--button-no-bg);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@ -369,6 +367,10 @@
|
||||
mask-repeat: no-repeat;
|
||||
mask-image: var(--views-manager-add-file-button-icon);
|
||||
}
|
||||
|
||||
> input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#viewsManagerCurrentOutlineButton {
|
||||
@ -388,17 +390,21 @@
|
||||
}
|
||||
|
||||
#viewsManagerStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
justify-content: space-between;
|
||||
width: auto;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
border: 1px solid var(--status-border-color);
|
||||
|
||||
> div {
|
||||
min-height: 64px;
|
||||
width: 100%;
|
||||
padding-inline: 16px;
|
||||
grid-area: 1 / 1;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.hidden {
|
||||
visibility: hidden;
|
||||
display: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.viewsManagerStatusLabel {
|
||||
|
||||
@ -89,7 +89,7 @@ class ViewsManager extends Sidebar {
|
||||
outlinesView,
|
||||
attachmentsView,
|
||||
layersView,
|
||||
viewsManagerAddFileButton,
|
||||
viewsManagerAddFile: { button: viewsManagerAddFileButton },
|
||||
viewsManagerCurrentOutlineButton,
|
||||
viewsManagerSelectorButton,
|
||||
viewsManagerSelectorOptions,
|
||||
@ -98,6 +98,7 @@ class ViewsManager extends Sidebar {
|
||||
},
|
||||
eventBus,
|
||||
l10n,
|
||||
enableMerge = false,
|
||||
enableSplitMerge = false,
|
||||
globalAbortSignal,
|
||||
}) {
|
||||
@ -149,6 +150,10 @@ class ViewsManager extends Sidebar {
|
||||
viewsManagerStatus.hidden = true;
|
||||
}
|
||||
this._enableSplitMerge = enableSplitMerge;
|
||||
this._enableMerge = enableMerge;
|
||||
if (!enableMerge) {
|
||||
viewsManagerAddFileButton.hidden = true;
|
||||
}
|
||||
|
||||
this.menu = new Menu(
|
||||
viewsManagerSelectorOptions,
|
||||
@ -260,10 +265,10 @@ class ViewsManager extends Sidebar {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._enableSplitMerge) {
|
||||
this.viewsManagerStatus.hidden = view !== SidebarView.THUMBS;
|
||||
}
|
||||
this.viewsManagerAddFileButton.hidden = view !== SidebarView.THUMBS;
|
||||
this.viewsManagerStatus.hidden =
|
||||
!this._enableSplitMerge || view !== SidebarView.THUMBS;
|
||||
this.viewsManagerAddFileButton.hidden =
|
||||
!this._enableMerge || view !== SidebarView.THUMBS;
|
||||
this.viewsManagerCurrentOutlineButton.hidden = view !== SidebarView.OUTLINE;
|
||||
this.viewsManagerHeaderLabel.setAttribute(
|
||||
"data-l10n-id",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user