Merge pull request #20670 from calixteman/reorg_delete

Add support for deleting, cutting, copying and pasting pages (bug 2010830, 2010831)
This commit is contained in:
calixteman 2026-02-18 17:52:35 +01:00 committed by GitHub
commit f609ee8a0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1041 additions and 228 deletions

View File

@ -766,3 +766,4 @@ pdfjs-views-manager-status-undo-button-label = Undo
pdfjs-views-manager-status-close-button =
.title = Close
pdfjs-views-manager-status-close-button-label = Close
pdfjs-views-manager-paste-button-label = Paste

View File

@ -2405,6 +2405,8 @@ class WorkerTransport {
#passwordCapability = null;
#copiedPageInfo = null;
constructor(
messageHandler,
loadingTask,
@ -2464,11 +2466,42 @@ class WorkerTransport {
}
}
#updateCaches() {
#updateCaches({ type, pageNumbers }) {
if (type === "copy") {
this.#copiedPageInfo = new Map();
for (const pageNum of pageNumbers) {
this.#copiedPageInfo.set(pageNum, {
proxy: this.#pageCache.get(pageNum - 1) || null,
promise: this.#pagePromises.get(pageNum - 1) || null,
});
}
return;
}
if (type === "delete") {
for (const pageNum of pageNumbers) {
this.#pageCache.delete(pageNum - 1);
this.#pagePromises.delete(pageNum - 1);
}
}
const newPageCache = new Map();
const newPromiseCache = new Map();
for (let i = 0, ii = this.pagesMapper.pagesNumber; i < ii; i++) {
const prevPageIndex = this.pagesMapper.getPrevPageNumber(i + 1) - 1;
const { pagesMapper } = this;
for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) {
const prevPageNumber = pagesMapper.getPrevPageNumber(i + 1);
if (prevPageNumber < 0) {
const { proxy, promise } =
this.#copiedPageInfo?.get(-prevPageNumber) || {};
if (proxy) {
newPageCache.set(i, proxy);
}
if (promise) {
newPromiseCache.set(i, promise);
}
continue;
}
const prevPageIndex = prevPageNumber - 1;
const page = this.#pageCache.get(prevPageIndex);
if (page) {
newPageCache.set(i, page);
@ -3001,7 +3034,11 @@ class WorkerTransport {
num: ref.num,
gen: ref.gen,
});
return this.pagesMapper.getPageNumber(index + 1) - 1;
const pageNumber = this.pagesMapper.getPageNumber(index + 1);
if (pageNumber === 0) {
throw new Error("GetPageIndex: page has been removed.");
}
return pageNumber - 1;
}
getAnnotations(pageIndex, intent) {
@ -3150,9 +3187,13 @@ class WorkerTransport {
}
const refStr = ref.gen === 0 ? `${ref.num}R` : `${ref.num}R${ref.gen}`;
const pageIndex = this.#pageRefCache.get(refStr);
return pageIndex >= 0
? this.pagesMapper.getPageNumber(pageIndex + 1)
: null;
if (pageIndex >= 0) {
const pageNumber = this.pagesMapper.getPageNumber(pageIndex + 1);
if (pageNumber !== 0) {
return pageNumber;
}
}
return null;
}
}

View File

@ -1046,7 +1046,7 @@ function makePathFromDrawOPS(data) {
class PagesMapper {
/**
* Maps page IDs to their corresponding page numbers.
* @type {Uint32Array|null}
* @type {Map<number, Array<number>>|null}
*/
#idToPageNumber = null;
@ -1058,9 +1058,9 @@ class PagesMapper {
/**
* Previous mapping of page IDs to page numbers.
* @type {Uint32Array|null}
* @type {Int32Array|null}
*/
#prevIdToPageNumber = null;
#prevPageNumbers = null;
/**
* The total number of pages.
@ -1074,6 +1074,19 @@ class PagesMapper {
*/
#listeners = [];
/**
* Maps page numbers to their corresponding page IDs (used in copy
* operations).
* @type {Uint32Array|null}
*/
#copiedPageIds = null;
/**
* Maps page IDs to their corresponding page numbers, used in copy operations.
* @type {Uint32Array|null}
*/
#copiedPageNumbers = null;
/**
* Gets the total number of pages.
* @returns {number} The number of pages.
@ -1092,16 +1105,33 @@ class PagesMapper {
return;
}
this.#pagesNumber = n;
if (n === 0) {
this.#pageNumberToId = null;
this.#idToPageNumber = null;
}
this.#reset();
}
/**
* Resets the page mappings to their default state, where page IDs equal page
* numbers (1-indexed). This is called when the number of pages changes, or
* when the current mapping matches the default mapping after a move
* operation.
*/
#reset() {
this.#pageNumberToId = null;
this.#idToPageNumber = null;
}
/**
* Adds a listener function that will be called whenever the page mappings
* are updated.
* @param {function} listener
*/
addListener(listener) {
this.#listeners.push(listener);
}
/**
* Removes a previously added listener function.
* @param {function} listener
*/
removeListener(listener) {
const index = this.#listeners.indexOf(listener);
if (index >= 0) {
@ -1109,28 +1139,56 @@ class PagesMapper {
}
}
#updateListeners() {
/**
* Calls all registered listener functions to notify them of changes to the
* page mappings.
* @param {Object} data - An object containing information about the update.
*/
#updateListeners(data) {
for (const listener of this.#listeners) {
listener();
listener(data);
}
}
/**
* Initializes the page mappings if they haven't been initialized yet.
* @param {boolean} mustInit
*/
#init(mustInit) {
if (this.#pageNumberToId) {
return;
}
const n = this.#pagesNumber;
// Allocate a single array for better memory locality.
const array = new Uint32Array(3 * n);
const pageNumberToId = (this.#pageNumberToId = array.subarray(0, n));
const idToPageNumber = (this.#idToPageNumber = array.subarray(n, 2 * n));
const pageNumberToId = (this.#pageNumberToId = new Uint32Array(n));
this.#prevPageNumbers = new Int32Array(pageNumberToId);
const idToPageNumber = (this.#idToPageNumber = new Map());
if (mustInit) {
for (let i = 0; i < n; i++) {
pageNumberToId[i] = idToPageNumber[i] = i + 1;
for (let i = 1; i <= n; i++) {
pageNumberToId[i - 1] = i;
idToPageNumber.set(i, [i]);
}
}
}
/**
* Updates the mapping from page IDs to page numbers based on the current
* mapping from page numbers to page IDs. This should be called after any
* changes to the page-number-to-ID mapping to keep the two mappings in sync.
*/
#updateIdToPageNumber() {
const idToPageNumber = this.#idToPageNumber;
const pageNumberToId = this.#pageNumberToId;
idToPageNumber.clear();
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
const id = pageNumberToId[i];
const pageNumbers = idToPageNumber.get(id);
if (pageNumbers) {
pageNumbers.push(i + 1);
} else {
idToPageNumber.set(id, [i + 1]);
}
}
this.#prevIdToPageNumber = array.subarray(2 * n);
}
/**
@ -1145,7 +1203,6 @@ class PagesMapper {
this.#init(true);
const pageNumberToId = this.#pageNumberToId;
const idToPageNumber = this.#idToPageNumber;
this.#prevIdToPageNumber.set(idToPageNumber);
const movedCount = pagesToMove.length;
const mappedPagesToMove = new Uint32Array(movedCount);
let removedBeforeTarget = 0;
@ -1182,17 +1239,118 @@ class PagesMapper {
// Finally insert the moved pages.
pageNumberToId.set(mappedPagesToMove, adjustedTarget);
let hasChanged = false;
for (let i = 0, ii = pagesNumber; i < ii; i++) {
const id = pageNumberToId[i];
hasChanged ||= id !== i + 1;
idToPageNumber[id - 1] = i + 1;
}
this.#updateListeners();
this.#setPrevPageNumbers(idToPageNumber, null);
this.#updateIdToPageNumber();
this.#updateListeners({ type: "move" });
if (!hasChanged) {
// Reset.
this.pagesNumber = 0;
if (pageNumberToId.every((id, i) => id === i + 1)) {
this.#reset();
}
}
/**
* Deletes a set of pages while keeping IDnumber mappings in sync.
* @param {Array<number>} pagesToDelete - Page numbers to delete (1-indexed).
* These must be unique and sorted in ascending order.
*/
deletePages(pagesToDelete) {
this.#init(true);
const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#idToPageNumber;
this.pagesNumber -= pagesToDelete.length;
this.#init(false);
const newPageNumberToId = this.#pageNumberToId;
let sourceIndex = 0;
let destIndex = 0;
for (const pageNumber of pagesToDelete) {
const pageIndex = pageNumber - 1;
if (pageIndex !== sourceIndex) {
newPageNumberToId.set(
pageNumberToId.subarray(sourceIndex, pageIndex),
destIndex
);
destIndex += pageIndex - sourceIndex;
}
sourceIndex = pageIndex + 1;
}
if (sourceIndex < pageNumberToId.length) {
newPageNumberToId.set(pageNumberToId.subarray(sourceIndex), destIndex);
}
this.#setPrevPageNumbers(prevIdToPageNumber, null);
this.#updateIdToPageNumber();
this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete });
}
/**
* Copies a set of pages while keeping IDnumber mappings in sync.
* @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed).
*/
copyPages(pagesToCopy) {
this.#init(true);
this.#copiedPageNumbers = pagesToCopy;
this.#copiedPageIds = pagesToCopy.map(
pageNumber => this.#pageNumberToId[pageNumber - 1]
);
this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy });
}
/**
* Pastes a set of pages while keeping IDnumber mappings in sync.
* @param {number} index - Zero-based insertion index in the page-number list.
*/
pastePages(index) {
this.#init(true);
const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#idToPageNumber;
const copiedPageNumbers = this.#copiedPageNumbers;
const copiedPageMapping = new Map();
let base = index;
for (const pageNumber of copiedPageNumbers) {
copiedPageMapping.set(++base, pageNumber);
}
this.pagesNumber += copiedPageNumbers.length;
this.#init(false);
const newPageNumberToId = this.#pageNumberToId;
newPageNumberToId.set(pageNumberToId.subarray(0, index), 0);
newPageNumberToId.set(this.#copiedPageIds, index);
newPageNumberToId.set(
pageNumberToId.subarray(index),
index + copiedPageNumbers.length
);
this.#setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping);
this.#updateIdToPageNumber();
this.#updateListeners({ type: "paste" });
this.#copiedPageIds = null;
}
/**
* Updates the previous page numbers based on the current page-number-to-ID
* mapping and the provided previous ID-to-page-number mapping.
* This is used to keep track of the original page numbers for each page ID.
* @param {Map<number, Array<number>} prevIdToPageNumber
* @param {Map<number, number>|null} copiedPageMapping
*/
#setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping) {
const prevPageNumbers = this.#prevPageNumbers;
const newPageNumberToId = this.#pageNumberToId;
const idsIndices = new Map();
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
const oldPageNumber = copiedPageMapping?.get(i + 1);
if (oldPageNumber) {
prevPageNumbers[i] = -oldPageNumber;
continue;
}
const id = newPageNumberToId[i];
const j = idsIndices.get(id) || 0;
prevPageNumbers[i] = prevIdToPageNumber.get(id)?.[j];
idsIndices.set(id, j + 1);
}
}
@ -1209,25 +1367,75 @@ class PagesMapper {
* @returns {Object} An object containing the page indices.
*/
getPageMappingForSaving() {
// Saving is index-based.
return {
pageIndices: this.#idToPageNumber
? this.#idToPageNumber.map(x => x - 1)
: null,
};
const idToPageNumber = this.#idToPageNumber;
// idToPageNumber maps used 1-based IDs to 1-based page numbers.
// For example if the final pdf contains page 3 twice and they are moved at
// page 1 and 4, then it contains:
// pageNumberToId = [3, ., ., 3, ...,]
// idToPageNumber = {3: [1, 4], ...}
// In such a case we need to take a page 3 from the original pdf and take
// page 3 from a "copy".
// So we need to pass to the api something like:
// [ {
// document: null // this pdf
// includePages: [ 2, ... ], // page 3 is at index 2
// pageIndices: [0, ...], // page 3 will be at index 0 in the new pdf
// }, {
// document: null // this pdf
// includePages: [ 2, ... ], // page 3 is at index 2
// pageIndices: [3, ...], // page 3 will be at index 3 in the new pdf
// }
// ]
let nCopy = 0;
for (const pageNumbers of idToPageNumber.values()) {
nCopy = Math.max(nCopy, pageNumbers.length);
}
const extractParams = new Array(nCopy);
for (let i = 0; i < nCopy; i++) {
extractParams[i] = {
document: null,
pageIndices: [],
includePages: [],
};
}
for (const [id, pageNumbers] of idToPageNumber) {
for (let i = 0, ii = pageNumbers.length; i < ii; i++) {
extractParams[i].includePages.push([id - 1, pageNumbers[i] - 1]);
}
}
for (const { includePages, pageIndices } of extractParams) {
includePages.sort((a, b) => a[0] - b[0]);
for (let i = 0, ii = includePages.length; i < ii; i++) {
pageIndices.push(includePages[i][1]);
includePages[i] = includePages[i][0];
}
}
return extractParams;
}
/**
* Gets the previous page number for a given page number.
* @param {number} pageNumber
* @returns {number} The previous page number for the given page number, or 0
* if no mapping exists.
*/
getPrevPageNumber(pageNumber) {
return this.#prevIdToPageNumber[this.#pageNumberToId[pageNumber - 1] - 1];
return this.#prevPageNumbers[pageNumber - 1] ?? 0;
}
/**
* Gets the page number for a given page ID.
* @param {number} id - The page ID (1-indexed).
* @returns {number} The page number, or the ID itself if no mapping exists.
* @returns {number} The page number, or 0 if no mapping exists.
*/
getPageNumber(id) {
return this.#idToPageNumber?.[id - 1] ?? id;
return this.#idToPageNumber ? (this.#idToPageNumber.get(id)?.[0] ?? 0) : id;
}
/**

View File

@ -145,7 +145,44 @@ class AnnotationEditorLayer {
}
updatePageIndex(newPageIndex) {
for (const editor of this.#allEditorsIterator) {
editor.updatePageIndex(newPageIndex);
}
this.pageIndex = newPageIndex;
this.#uiManager.addLayer(this);
}
/**
* Clones all annotation editors from another layer into this layer.
* This is typically used when duplicating a page - the editors from the
* source page are serialized and then deserialized into the new page's layer.
*
* @param {AnnotationEditorLayer} clonedFrom - The source annotation editor
* layer to clone editors from. If null or undefined, no action is taken.
* @returns {Promise<void>} A promise that resolves when all editors have been
* cloned and added to this layer.
*/
async setClonedFrom(clonedFrom) {
if (!clonedFrom) {
return;
}
const promises = [];
for (const editor of clonedFrom.#allEditorsIterator) {
const serialized = editor.serialize(/* isForCopying = */ true);
if (!serialized) {
continue;
}
serialized.isCopy = false;
promises.push(
this.deserialize(serialized).then(deserialized => {
if (deserialized) {
this.addOrRebuild(deserialized);
}
})
);
}
await Promise.all(promises);
}
get isEmpty() {

View File

@ -948,7 +948,6 @@ class AnnotationEditorUIManager {
evt => this.updateParams(evt.type, evt.value),
{ signal }
);
eventBus._on("pagesedited", this.onPagesEdited.bind(this), { signal });
window.addEventListener(
"pointerdown",
() => {
@ -1264,30 +1263,20 @@ class AnnotationEditorUIManager {
}
}
onPagesEdited({ pagesMapper }) {
for (const editor of this.#allEditors.values()) {
editor.updatePageIndex(
pagesMapper.getPrevPageNumber(editor.pageIndex + 1) - 1
);
}
const allLayers = this.#allLayers;
const newAllLayers = (this.#allLayers = new Map());
for (const [pageIndex, layer] of allLayers) {
const prevPageIndex = pagesMapper.getPrevPageNumber(pageIndex + 1) - 1;
if (prevPageIndex === -1) {
// TODO: handle the case where the deletion of the page has been undone.
layer.destroy();
continue;
}
newAllLayers.set(prevPageIndex, layer);
layer.updatePageIndex(prevPageIndex);
}
}
onPageChanging({ pageNumber }) {
this.#currentPageIndex = pageNumber - 1;
}
deletePage(id) {
for (const editor of this.getEditors(id)) {
editor.remove();
}
this.#allLayers.delete(id);
if (this.#currentPageIndex === id) {
this.#currentPageIndex = 0;
}
}
focusMainContainer() {
this.#container.focus();
}

View File

@ -18,19 +18,21 @@ import {
clearInput,
closePages,
createPromise,
createPromiseWithArgs,
dragAndDrop,
getAnnotationSelector,
getRect,
getThumbnailSelector,
loadAndWait,
scrollIntoView,
waitAndClick,
waitForDOMMutation,
} from "./test_utils.mjs";
async function waitForThumbnailVisible(page, pageNums) {
await page.click("#viewsManagerToggleButton");
const thumbSelector = "#thumbnailsView .thumbnailImage";
const thumbSelector = "#thumbnailsView .thumbnailImageContainer > img";
await page.waitForSelector(thumbSelector, { visible: true });
if (!pageNums) {
return null;
@ -45,18 +47,22 @@ async function waitForThumbnailVisible(page, pageNums) {
);
}
function waitForPagesEdited(page) {
return createPromise(page, resolve => {
window.PDFViewerApplication.eventBus.on(
"pagesedited",
({ pagesMapper }) => {
function waitForPagesEdited(page, type) {
return createPromiseWithArgs(
page,
resolve => {
const listener = ({ pagesMapper, type: ty }) => {
// eslint-disable-next-line no-undef
if (args[0] && args[0] !== ty) {
return;
}
window.PDFViewerApplication.eventBus.off("pagesedited", listener);
resolve(Array.from(pagesMapper.getMapping()));
},
{
once: true,
}
);
});
};
window.PDFViewerApplication.eventBus.on("pagesedited", listener);
},
[type]
);
}
async function waitForHavingContents(page, expected) {
@ -533,7 +539,8 @@ describe("Reorganize Pages View", () => {
await page.waitForSelector("#thumbnailsViewMenu", { visible: true });
await page.click("#thumbnailsViewMenu");
const thumbSelector = "#thumbnailsView .thumbnailImage";
const thumbSelector =
"#thumbnailsView .thumbnailImageContainer > img";
await page.waitForSelector(thumbSelector, { visible: true });
const rect1 = await getRect(page, getThumbnailSelector(1));
const rect2 = await getRect(page, getThumbnailSelector(2));
@ -607,7 +614,7 @@ describe("Reorganize Pages View", () => {
window.PDFViewerApplication.eventBus.on(
"savepageseditedpdf",
({ data }) => {
resolve(Array.from(data.pageIndices));
resolve(Array.from(data[0].pageIndices));
},
{
once: true,
@ -630,4 +637,174 @@ describe("Reorganize Pages View", () => {
);
});
});
describe("Delete some pages", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should check that the pages are deleted", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(3)}) input`
);
const handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionDelete");
const pageIndices = await awaitPromise(handlePagesEdited);
const expected = [
2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
];
expect(pageIndices)
.withContext(`In ${browserName}`)
.toEqual(expected);
await waitForHavingContents(page, expected);
})
);
});
});
describe("Cut and paste some pages", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should check that the pages has been cut and pasted correctly", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(3)}) input`
);
let handlePagesEdited = await waitForPagesEdited(page, "cut");
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionCut");
let pageIndices = await awaitPromise(handlePagesEdited);
let expected = [2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17];
expect(pageIndices)
.withContext(`In ${browserName}`)
.toEqual(expected);
await waitForHavingContents(page, expected);
handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, `${getThumbnailSelector(1)}+button`);
pageIndices = await awaitPromise(handlePagesEdited);
expected = [
2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
];
expect(pageIndices)
.withContext(`In ${browserName}`)
.toEqual(expected);
await waitForHavingContents(page, expected);
})
);
});
});
describe("Copy and paste some pages", () => {
let pages;
beforeEach(async () => {
pages = await loadAndWait(
"page_with_number.pdf",
"#viewsManagerToggleButton",
"1",
null,
{ enableSplitMerge: true }
);
});
afterEach(async () => {
await closePages(pages);
});
it("should check that the pages has been copied and pasted correctly", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await waitForThumbnailVisible(page, 1);
await page.waitForSelector("#viewsManagerStatusActionButton", {
visible: true,
});
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(1)}) input`
);
await waitAndClick(
page,
`.thumbnail:has(${getThumbnailSelector(3)}) input`
);
let handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, "#viewsManagerStatusActionButton");
await waitAndClick(page, "#viewsManagerStatusActionCopy");
let pageIndices = await awaitPromise(handlePagesEdited);
let expected = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
];
expect(pageIndices)
.withContext(`In ${browserName}`)
.toEqual(expected);
await waitForHavingContents(page, expected);
handlePagesEdited = await waitForPagesEdited(page);
await waitAndClick(page, `${getThumbnailSelector(2)}+button`);
pageIndices = await awaitPromise(handlePagesEdited);
expected = [
1, 2, 1, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
];
expect(pageIndices)
.withContext(`In ${browserName}`)
.toEqual(expected);
await waitForHavingContents(page, expected);
})
);
});
});
});

View File

@ -131,6 +131,15 @@ function createPromise(page, callback) {
);
}
function createPromiseWithArgs(page, callback, args) {
return page.evaluateHandle(
// eslint-disable-next-line no-eval, no-shadow
(cb, args) => [new Promise(eval(`(${cb})`))],
callback.toString(),
args
);
}
function awaitPromise(promise) {
return promise.evaluate(([p]) => p);
}
@ -253,7 +262,7 @@ function getAnnotationSelector(id) {
}
function getThumbnailSelector(pageNumber) {
return `.thumbnailImage[data-l10n-args='{"page":${pageNumber}}']`;
return `.thumbnailImageContainer[data-l10n-args='{"page":${pageNumber}}']`;
}
async function getSpanRectFromText(page, pageNumber, text) {
@ -963,6 +972,7 @@ export {
countSerialized,
countStorageEntries,
createPromise,
createPromiseWithArgs,
dragAndDrop,
firstPageOnTop,
FSI,

View File

@ -9,7 +9,7 @@ import {
function waitForThumbnailVisible(page, pageNum) {
return page.waitForSelector(
`.thumbnailImage[data-l10n-args='{"page":${pageNum}}']`,
`.thumbnailImageContainer[data-l10n-args='{"page":${pageNum}}']`,
{ visible: true }
);
}
@ -46,7 +46,8 @@ describe("PDF Thumbnail View", () => {
pages.map(async ([browserName, page]) => {
await page.click("#viewsManagerToggleButton");
const thumbSelector = "#thumbnailsView .thumbnailImage";
const thumbSelector =
"#thumbnailsView .thumbnailImageContainer > img";
await page.waitForSelector(thumbSelector, { visible: true });
await waitForThumbnailVisible(page, 1);
@ -110,12 +111,15 @@ describe("PDF Thumbnail View", () => {
for (const pageNum of [14, 1, 13, 2]) {
await goToPage(page, pageNum);
const thumbSelector = `.thumbnailImage[data-l10n-args='{"page":${pageNum}}']`;
const thumbSelector = `.thumbnailImageContainer[data-l10n-args='{"page":${pageNum}}']`;
await page.waitForSelector(
`.thumbnail ${thumbSelector}[aria-current="page"]`,
{ visible: true }
);
const src = await page.$eval(thumbSelector, el => el.src);
const src = await page.$eval(
`${thumbSelector} > img`,
el => el.src
);
expect(src)
.withContext(`In ${browserName}`)
.toMatch(/^blob:http:/);
@ -167,7 +171,7 @@ describe("PDF Thumbnail View", () => {
expect(
await isElementFocused(
page,
`#thumbnailsView .thumbnailImage[data-l10n-args='{"page":1}']`
`#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":1}']`
)
)
.withContext(`In ${browserName}`)
@ -177,7 +181,7 @@ describe("PDF Thumbnail View", () => {
expect(
await isElementFocused(
page,
`#thumbnailsView .thumbnailImage[data-l10n-args='{"page":2}']`
`#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":2}']`
)
)
.withContext(`In ${browserName}`)
@ -187,7 +191,7 @@ describe("PDF Thumbnail View", () => {
expect(
await isElementFocused(
page,
`#thumbnailsView .thumbnailImage[data-l10n-args='{"page":1}']`
`#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":1}']`
)
)
.withContext(`In ${browserName}`)
@ -198,7 +202,7 @@ describe("PDF Thumbnail View", () => {
expect(
await isElementFocused(
page,
`#thumbnailsView .thumbnailImage[data-l10n-args='{"page":3}']`
`#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":3}']`
)
)
.withContext(`In ${browserName}`)
@ -214,7 +218,7 @@ describe("PDF Thumbnail View", () => {
expect(
await isElementFocused(
page,
`#thumbnailsView .thumbnailImage[data-l10n-args='{"page":14}']`
`#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":14}']`
)
)
.withContext(`In ${browserName}`)
@ -224,7 +228,7 @@ describe("PDF Thumbnail View", () => {
expect(
await isElementFocused(
page,
`#thumbnailsView .thumbnailImage[data-l10n-args='{"page":1}']`
`#thumbnailsView .thumbnailImageContainer[data-l10n-args='{"page":1}']`
)
)
.withContext(`In ${browserName}`)

View File

@ -31,7 +31,7 @@ import { GenericL10n } from "web-null_l10n";
/**
* @typedef {Object} AnnotationEditorLayerBuilderOptions
* @property {AnnotationEditorUIManager} [uiManager]
* @property {PDFPageProxy} pdfPage
* @property {number} pageIndex
* @property {L10n} [l10n]
* @property {StructTreeLayerBuilder} [structTreeLayer]
* @property {TextAccessibilityManager} [accessibilityManager]
@ -39,6 +39,7 @@ import { GenericL10n } from "web-null_l10n";
* @property {TextLayer} [textLayer]
* @property {DrawLayer} [drawLayer]
* @property {function} [onAppend]
* @property {AnnotationEditorLayer} [clonedFrom]
*/
/**
@ -60,11 +61,13 @@ class AnnotationEditorLayerBuilder {
#uiManager;
#clonedFrom = null;
/**
* @param {AnnotationEditorLayerBuilderOptions} options
*/
constructor(options) {
this.pdfPage = options.pdfPage;
this.pageIndex = options.pageIndex;
this.accessibilityManager = options.accessibilityManager;
this.l10n = options.l10n;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
@ -79,6 +82,12 @@ class AnnotationEditorLayerBuilder {
this.#drawLayer = options.drawLayer || null;
this.#onAppend = options.onAppend || null;
this.#structTreeLayer = options.structTreeLayer || null;
this.#clonedFrom = options.clonedFrom || null;
}
updatePageIndex(newPageIndex) {
this.pageIndex = newPageIndex;
this.annotationEditorLayer?.updatePageIndex(newPageIndex);
}
/**
@ -113,7 +122,7 @@ class AnnotationEditorLayerBuilder {
div,
structTreeLayer: this.#structTreeLayer,
accessibilityManager: this.accessibilityManager,
pageIndex: this.pdfPage.pageNumber - 1,
pageIndex: this.pageIndex,
l10n: this.l10n,
viewport: clonedViewport,
annotationLayer: this.#annotationLayer,
@ -121,6 +130,11 @@ class AnnotationEditorLayerBuilder {
drawLayer: this.#drawLayer,
});
this.annotationEditorLayer.setClonedFrom(
this.#clonedFrom?.annotationEditorLayer
);
this.#clonedFrom = null;
const parameters = {
viewport: clonedViewport,
div,

View File

@ -2185,11 +2185,6 @@ const PDFViewerApplication = {
);
}
eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts);
eventBus._on(
"beforepagesedited",
this.onBeforePagesEdited.bind(this),
opts
);
eventBus._on(
"savepageseditedpdf",
this.onSavePagesEditedPDF.bind(this),
@ -2369,30 +2364,18 @@ const PDFViewerApplication = {
await Promise.all([this.l10n?.destroy(), this.close()]);
},
onBeforePagesEdited(data) {
this.pdfViewer.onBeforePagesEdited(data);
},
onPagesEdited(data) {
this.pdfViewer.onPagesEdited(data);
},
async onSavePagesEditedPDF({
data: { includePages, excludePages, pageIndices },
}) {
async onSavePagesEditedPDF({ data: extractParams }) {
if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) {
return;
}
if (!this.pdfDocument) {
return;
}
const pageInfo = {
document: null, // For now, no merge.
includePages,
excludePages,
pageIndices,
};
const modifiedPdfBytes = await this.pdfDocument.extractPages([pageInfo]);
const modifiedPdfBytes = await this.pdfDocument.extractPages(extractParams);
if (!modifiedPdfBytes) {
console.error(
"Something wrong happened when saving the edited PDF.\nPlease file a bug."

View File

@ -19,8 +19,6 @@ import { RenderingCancelledException } from "pdfjs-lib";
class BasePDFPageView extends RenderableView {
#loadingId = null;
#minDurationToUpdateCanvas = 0;
#renderError = null;
#renderingState = RenderingStates.INITIAL;
@ -56,7 +54,7 @@ class BasePDFPageView extends RenderableView {
this.renderingQueue = options.renderingQueue;
this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false;
this.#minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500;
this.minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500;
}
get renderingState() {
@ -116,14 +114,14 @@ class BasePDFPageView extends RenderableView {
this.#showCanvas = isLastShow => {
if (updateOnFirstShow) {
let tempCanvas = this.#tempCanvas;
if (!isLastShow && this.#minDurationToUpdateCanvas > 0) {
if (!isLastShow && this.minDurationToUpdateCanvas > 0) {
// We draw on the canvas at 60fps (in using `requestAnimationFrame`),
// so if the canvas is large, updating it at 60fps can be a way too
// much and can cause some serious performance issues.
// To avoid that we only update the canvas every
// `this.#minDurationToUpdateCanvas` ms.
if (Date.now() - this.#startTime < this.#minDurationToUpdateCanvas) {
if (Date.now() - this.#startTime < this.minDurationToUpdateCanvas) {
return;
}
if (!tempCanvas) {

View File

@ -422,6 +422,8 @@ class PDFFindController {
#visitedPagesCount = 0;
#copiedExtractTextPromises = null;
/**
* @param {PDFFindControllerOptions} options
*/
@ -609,6 +611,7 @@ class PDFFindController {
this._dirtyMatch = false;
clearTimeout(this._findTimeout);
this._findTimeout = null;
this.#copiedExtractTextPromises = null;
this._firstPageCapability = Promise.withResolvers();
}
@ -1127,21 +1130,37 @@ class PDFFindController {
}
}
#onPagesEdited({ pagesMapper }) {
#onPagesEdited({ pagesMapper, type, pageNumbers }) {
if (this._extractTextPromises.length === 0) {
return;
}
if (type === "copy") {
this.#copiedExtractTextPromises = new Map();
for (const pageNum of pageNumbers) {
this.#copiedExtractTextPromises.set(
pageNum,
this._extractTextPromises[pageNum - 1]
);
}
return;
}
this.#onFindBarClose();
this._dirtyMatch = true;
const prevTextPromises = this._extractTextPromises;
const extractTextPromises = (this._extractTextPromises.length = []);
for (let i = 0, ii = pagesMapper.length; i < ii; i++) {
const prevPageIndex = pagesMapper.getPrevPageNumber(i + 1) - 1;
if (prevPageIndex === -1) {
for (let i = 1, ii = pagesMapper.length; i <= ii; i++) {
const prevPageNumber = pagesMapper.getPrevPageNumber(i);
if (prevPageNumber < 0) {
extractTextPromises.push(
this.#copiedExtractTextPromises?.get(-prevPageNumber) ||
Promise.resolve()
);
continue;
}
extractTextPromises.push(
prevTextPromises[prevPageIndex] || Promise.resolve()
prevTextPromises[prevPageNumber - 1] || Promise.resolve()
);
}
}

View File

@ -85,7 +85,7 @@ class PDFLinkService {
* @type {number}
*/
get pagesCount() {
return this.pdfDocument ? this.pdfDocument.numPages : 0;
return this.pdfDocument?.pagesMapper.pagesNumber || 0;
}
/**

View File

@ -103,6 +103,8 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js";
* @property {boolean} [enableAutoLinking] - Enable creation of hyperlinks from
* text that look like URLs. The default value is `true`.
* @property {CommentManager} [commentManager] - The comment manager instance.
* @property {PDFPageView} [clonedFrom] - The page view that is cloned
* to.
*/
const DEFAULT_LAYER_PROPERTIES =
@ -166,6 +168,8 @@ class PDFPageView extends BasePDFPageView {
#layers = [null, null, null, null];
#clonedFrom = null;
/**
* @param {PDFPageViewOptions} options
*/
@ -197,6 +201,7 @@ class PDFPageView extends BasePDFPageView {
options.capCanvasAreaFactor ?? AppOptions.get("capCanvasAreaFactor");
this.#enableAutoLinking = options.enableAutoLinking !== false;
this.#commentManager = options.commentManager || null;
this.#clonedFrom = options.clonedFrom || null;
this.l10n = options.l10n;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
@ -226,7 +231,6 @@ class PDFPageView extends BasePDFPageView {
div.setAttribute("data-l10n-id", "pdfjs-page-landmark");
div.setAttribute("data-l10n-args", JSON.stringify({ page: this.id }));
this.div = div;
this.#setDimensions();
container?.append(div);
@ -270,6 +274,35 @@ class PDFPageView extends BasePDFPageView {
}
}
clone(id) {
const clone = new PDFPageView({
container: null,
eventBus: this.eventBus,
pagesColors: this.pageColors,
renderingQueue: this.renderingQueue,
enableOptimizedPartialRendering: this.enableOptimizedPartialRendering,
minDurationToUpdateCanvas: this.minDurationToUpdateCanvas,
defaultViewport: this.viewport,
id,
layerProperties: this.#layerProperties,
scale: this.scale,
optionalContentConfigPromise: this._optionalContentConfigPromise,
textLayerMode: this.#textLayerMode,
annotationMode: this.#annotationMode,
imageResourcesPath: this.imageResourcesPath,
enableDetailCanvas: this.enableDetailCanvas,
maxCanvasPixels: this.maxCanvasPixels,
maxCanvasDim: this.maxCanvasDim,
capCanvasAreaFactor: this.capCanvasAreaFactor,
enableAutoLinking: this.#enableAutoLinking,
commentManager: this.#commentManager,
l10n: this.l10n,
clonedFrom: this,
});
clone.setPdfPage(this.pdfPage);
return clone;
}
#addLayer(div, name) {
const pos = LAYERS_ORDER.get(name);
const oldDiv = this.#layers[pos];
@ -331,6 +364,7 @@ class PDFPageView extends BasePDFPageView {
this._textHighlighter.pageIdx = newPageNumber - 1;
// Don't update the page index for the draw layer, since it's just used as
// an identifier.
this.annotationEditorLayer?.updatePageIndex(newPageNumber - 1);
}
setPdfPage(pdfPage) {
@ -378,6 +412,15 @@ class PDFPageView extends BasePDFPageView {
this.pdfPage?.cleanup();
}
deleteMe(isCut) {
if (isCut) {
this.div.remove();
return;
}
this.destroy();
this.#layerProperties.annotationEditorUIManager?.deletePage(this.id);
}
hasEditableAnnotations() {
return !!this.annotationLayer?.hasEditableAnnotations();
}
@ -1140,17 +1183,19 @@ class PDFPageView extends BasePDFPageView {
) {
this.annotationEditorLayer ||= new AnnotationEditorLayerBuilder({
uiManager: annotationEditorUIManager,
pdfPage,
pageIndex: this.id - 1,
l10n,
structTreeLayer: this.structTreeLayer,
accessibilityManager: this._accessibilityManager,
annotationLayer: this.annotationLayer?.annotationLayer,
textLayer: this.textLayer,
drawLayer: this.drawLayer.getDrawLayer(),
clonedFrom: this.#clonedFrom?.annotationEditorLayer,
onAppend: annotationEditorLayerDiv => {
this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer");
},
});
this.#clonedFrom = null;
this.#renderAnnotationEditorLayer();
}
});

View File

@ -116,10 +116,10 @@ class PDFThumbnailView extends RenderableView {
this.placeholder = null;
const imageContainer = (this.div = document.createElement("div"));
imageContainer.className = "thumbnail";
imageContainer.setAttribute("page-number", id);
imageContainer.setAttribute("page-id", id);
const thumbnailContainer = (this.div = document.createElement("div"));
thumbnailContainer.className = "thumbnail";
thumbnailContainer.setAttribute("page-number", id);
thumbnailContainer.setAttribute("page-id", id);
if (enableSplitMerge) {
const checkbox = (this.checkbox = document.createElement("input"));
@ -127,24 +127,80 @@ class PDFThumbnailView extends RenderableView {
checkbox.tabIndex = -1;
checkbox.setAttribute("data-l10n-id", "pdfjs-thumb-page-checkbox");
checkbox.setAttribute("data-l10n-args", this.#pageL10nArgs);
imageContainer.append(checkbox);
thumbnailContainer.append(checkbox);
this.pasteButton = null;
}
const imageContainer = (this.imageContainer =
document.createElement("div"));
thumbnailContainer.append(imageContainer);
imageContainer.classList.add(
"thumbnailImageContainer",
"missingThumbnailImage"
);
imageContainer.role = "button";
imageContainer.tabIndex = -1;
imageContainer.draggable = false;
imageContainer.setAttribute("page-number", id);
const image = (this.image = document.createElement("img"));
image.classList.add("thumbnailImage", "missingThumbnailImage");
image.role = "button";
image.tabIndex = -1;
image.draggable = false;
imageContainer.append(image);
this.#updateDims();
imageContainer.append(image);
container.append(imageContainer);
container.append(thumbnailContainer);
}
clone(container, id) {
const thumbnailView = new PDFThumbnailView({
container,
id,
eventBus: this.eventBus,
defaultViewport: this.viewport,
optionalContentConfigPromise: this._optionalContentConfigPromise,
linkService: this.linkService,
renderingQueue: this.renderingQueue,
maxCanvasPixels: this.maxCanvasPixels,
maxCanvasDim: this.maxCanvasDim,
pageColors: this.pageColors,
enableSplitMerge: !!this.checkbox,
});
thumbnailView.setPdfPage(this.pdfPage);
const { imageContainer } = this;
if (!imageContainer.classList.contains("missingThumbnailImage")) {
thumbnailView.image.replaceWith(this.image.cloneNode(true));
thumbnailView.imageContainer.classList.remove("missingThumbnailImage");
}
return thumbnailView;
}
addPasteButton(pasteCallback) {
if (this.pasteButton) {
return;
}
const pasteButton = (this.pasteButton = document.createElement("button"));
pasteButton.classList.add("thumbnailPasteButton", "viewsManagerButton");
pasteButton.tabIndex = 0;
const span = document.createElement("span");
span.setAttribute("data-l10n-id", "pdfjs-views-manager-paste-button-label");
pasteButton.append(span);
pasteButton.addEventListener("click", () => {
pasteCallback(this.id);
});
this.imageContainer.after(pasteButton);
}
toggleSelected(isSelected) {
if (this.checkbox) {
this.checkbox.checked = isSelected;
}
}
updateId(newId) {
this.id = newId;
this.renderingId = `thumbnail${newId}`;
this.div.setAttribute("page-number", newId);
this.imageContainer.setAttribute("page-number", newId);
// TODO: do we set the page label ?
this.setPageLabel(this.pageLabel);
}
@ -157,7 +213,7 @@ class PDFThumbnailView extends RenderableView {
const canvasHeight = (this.canvasHeight = (canvasWidth / ratio) | 0);
this.scale = canvasWidth / width;
this.image.style.height = `${canvasHeight}px`;
this.imageContainer.style.height = `${canvasHeight}px`;
}
get renderingState() {
@ -181,17 +237,23 @@ class PDFThumbnailView extends RenderableView {
this.renderingState = RenderingStates.INITIAL;
this.#updateDims();
const { image } = this;
const { image, imageContainer } = this;
const url = image.src;
if (url) {
URL.revokeObjectURL(url);
image.removeAttribute("data-l10n-id");
image.removeAttribute("data-l10n-args");
image.src = "";
this.image.classList.add("missingThumbnailImage");
imageContainer.removeAttribute("data-l10n-id");
imageContainer.removeAttribute("data-l10n-args");
imageContainer.classList.add("missingThumbnailImage");
}
}
destroy() {
this.reset();
this.toggleCurrent(false);
this.div.remove();
}
update({ rotation = null }) {
if (typeof rotation === "number") {
this.rotation = rotation; // The rotation may be zero.
@ -205,12 +267,13 @@ class PDFThumbnailView extends RenderableView {
}
toggleCurrent(isCurrent) {
const { imageContainer } = this;
if (isCurrent) {
this.image.ariaCurrent = "page";
this.image.tabIndex = 0;
imageContainer.ariaCurrent = "page";
imageContainer.tabIndex = 0;
} else {
this.image.ariaCurrent = false;
this.image.tabIndex = -1;
imageContainer.ariaCurrent = false;
imageContainer.tabIndex = -1;
}
}
@ -257,14 +320,14 @@ class PDFThumbnailView extends RenderableView {
throw new Error("#convertCanvasToImage: Rendering has not finished.");
}
const reducedCanvas = this.#reduceImage(canvas);
const { image } = this;
const { imageContainer, image } = this;
const { promise, resolve } = Promise.withResolvers();
reducedCanvas.toBlob(resolve);
const blob = await promise;
image.src = URL.createObjectURL(blob);
image.setAttribute("data-l10n-id", "pdfjs-thumb-page-canvas");
image.setAttribute("data-l10n-args", this.#pageL10nArgs);
image.classList.remove("missingThumbnailImage");
imageContainer.setAttribute("data-l10n-id", "pdfjs-thumb-page-canvas");
imageContainer.setAttribute("data-l10n-args", this.#pageL10nArgs);
imageContainer.classList.remove("missingThumbnailImage");
if (!FeatureTest.isOffscreenCanvasSupported) {
// Clean up the canvas element since it is no longer needed.
reducedCanvas.width = reducedCanvas.height = 0;
@ -465,7 +528,7 @@ class PDFThumbnailView extends RenderableView {
*/
setPageLabel(label) {
this.pageLabel = typeof label === "string" ? label : null;
this.image.setAttribute("data-l10n-args", this.#pageL10nArgs);
this.imageContainer.setAttribute("data-l10n-args", this.#pageL10nArgs);
this.checkbox?.setAttribute("data-l10n-args", this.#pageL10nArgs);
}
}

View File

@ -114,6 +114,18 @@ class PDFThumbnailViewer {
#manageSaveAsButton = null;
#manageDeleteButton = null;
#manageCopyButton = null;
#manageCutButton = null;
#copiedThumbnails = null;
#copiedPageNumbers = null;
#isCut = false;
/**
* @param {PDFThumbnailViewerOptions} options
*/
@ -143,6 +155,14 @@ class PDFThumbnailViewer {
if (this.#enableSplitMerge && manageMenu) {
const { button, menu, copy, cut, delete: del, saveAs } = manageMenu;
this.eventBus.on(
"pagesloaded",
() => {
button.disabled = false;
},
{ once: true }
);
this._manageMenu = new Menu(menu, button, [copy, cut, del, saveAs]);
this.#manageSaveAsButton = saveAs;
saveAs.addEventListener("click", () => {
@ -151,6 +171,15 @@ class PDFThumbnailViewer {
data: this.#pagesMapper.getPageMappingForSaving(),
});
});
this.#manageDeleteButton = del;
del.addEventListener("click", this.#deletePages.bind(this));
this.#manageCopyButton = copy;
copy.addEventListener("click", this.#copyPages.bind(this));
this.#manageCutButton = cut;
cut.addEventListener("click", this.#cutPages.bind(this));
this.#toggleMenuEntries(false);
button.disabled = true;
} else {
manageMenu.button.hidden = true;
}
@ -191,7 +220,7 @@ class PDFThumbnailViewer {
}
if (pageNumber !== this._currentPageNumber) {
const prevThumbnailView = this._thumbnails[this._currentPageNumber - 1];
prevThumbnailView.toggleCurrent(/* isCurrent = */ false);
prevThumbnailView?.toggleCurrent(/* isCurrent = */ false);
thumbnailView.toggleCurrent(/* isCurrent = */ true);
this._currentPageNumber = pageNumber;
}
@ -200,15 +229,11 @@ class PDFThumbnailViewer {
// If the thumbnail isn't currently visible, scroll it into view.
if (views.length > 0) {
let shouldScroll = false;
if (
pageNumber <= this.#pagesMapper.getPageNumber(first.id) ||
pageNumber >= this.#pagesMapper.getPageNumber(last.id)
) {
if (pageNumber <= first.id || pageNumber >= last.id) {
shouldScroll = true;
} else {
for (const { id, percent } of views) {
const mappedPageNumber = this.#pagesMapper.getPageNumber(id);
if (mappedPageNumber !== pageNumber) {
if (id !== pageNumber) {
continue;
}
shouldScroll = percent < 100;
@ -403,24 +428,45 @@ class PDFThumbnailViewer {
));
}
#updateThumbnails() {
#updateThumbnails(currentPageNumber) {
let newCurrentPageNumber = 0;
const pagesMapper = this.#pagesMapper;
this.container.replaceChildren();
const prevThumbnails = this._thumbnails;
const newThumbnails = (this._thumbnails = []);
const fragment = document.createDocumentFragment();
for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) {
const prevPageIndex = pagesMapper.getPrevPageNumber(i + 1) - 1;
if (prevPageIndex === -1) {
const isCut = this.#isCut;
const oldThumbnails = new Set(prevThumbnails);
for (let i = 1, ii = pagesMapper.pagesNumber; i <= ii; i++) {
const prevPageNumber = pagesMapper.getPrevPageNumber(i);
if (prevPageNumber < 0) {
let thumbnail = this.#copiedThumbnails.get(-prevPageNumber);
oldThumbnails.delete(thumbnail);
thumbnail.checkbox.checked = false;
if (isCut) {
thumbnail.updateId(i);
fragment.append(thumbnail.div);
} else {
thumbnail = thumbnail.clone(fragment, i);
}
newThumbnails.push(thumbnail);
continue;
}
const newThumbnail = prevThumbnails[prevPageIndex];
if (prevPageNumber === currentPageNumber) {
newCurrentPageNumber = i;
}
const newThumbnail = prevThumbnails[prevPageNumber - 1];
newThumbnails.push(newThumbnail);
newThumbnail.updateId(i + 1);
newThumbnail.updateId(i);
oldThumbnails.delete(newThumbnail);
newThumbnail.checkbox.checked = false;
fragment.append(newThumbnail.div);
}
this.container.append(fragment);
for (const oldThumbnail of oldThumbnails) {
oldThumbnail.destroy();
}
return newCurrentPageNumber;
}
#onStartDragging(draggedThumbnail) {
@ -443,18 +489,18 @@ class PDFThumbnailViewer {
const thumbnail = this._thumbnails[selected - 1];
const placeholder = (thumbnail.placeholder =
document.createElement("div"));
placeholder.classList.add("thumbnailImage", "placeholder");
const { div, image } = thumbnail;
placeholder.classList.add("thumbnailImageContainer", "placeholder");
const { div, imageContainer } = thumbnail;
div.classList.add("isDragging");
placeholder.style.height = getComputedStyle(image).height;
image.after(placeholder);
placeholder.style.height = getComputedStyle(imageContainer).height;
imageContainer.after(placeholder);
if (selected !== startPageNumber) {
image.classList.add("hidden");
imageContainer.classList.add("hidden");
continue;
}
if (this.#selectedPages.size === 1) {
image.classList.add("draggingThumbnail");
this.#draggedContainer = image;
imageContainer.classList.add("draggingThumbnail");
this.#draggedContainer = imageContainer;
continue;
}
// For multiple selected thumbnails, only the one being dragged is shown
@ -463,13 +509,13 @@ class PDFThumbnailViewer {
document.createElement("div"));
draggedContainer.classList.add(
"draggingThumbnail",
"thumbnailImage",
"thumbnailImageContainer",
"multiple"
);
draggedContainer.style.height = getComputedStyle(image).height;
image.replaceWith(draggedContainer);
image.classList.remove("thumbnailImage");
draggedContainer.append(image);
draggedContainer.style.height = getComputedStyle(imageContainer).height;
imageContainer.replaceWith(draggedContainer);
imageContainer.classList.remove("thumbnailImageContainer");
draggedContainer.append(imageContainer);
draggedContainer.setAttribute(
"data-multiple-count",
this.#selectedPages.size
@ -490,17 +536,17 @@ class PDFThumbnailViewer {
this.container.classList.remove("isDragging");
for (const selected of this.#selectedPages) {
const thumbnail = this._thumbnails[selected - 1];
const { div, placeholder, image } = thumbnail;
const { div, placeholder, imageContainer } = thumbnail;
placeholder.remove();
image.classList.remove("draggingThumbnail", "hidden");
imageContainer.classList.remove("draggingThumbnail", "hidden");
div.classList.remove("isDragging");
}
if (draggedContainer.classList.contains("multiple")) {
// Restore the dragged image to its thumbnail.
const originalImage = draggedContainer.firstElementChild;
draggedContainer.replaceWith(originalImage);
originalImage.classList.add("thumbnailImage");
const originalImageContainer = draggedContainer.firstElementChild;
draggedContainer.replaceWith(originalImageContainer);
originalImageContainer.classList.add("thumbnailImageContainer");
} else {
draggedContainer.style.translate = "";
}
@ -515,45 +561,36 @@ class PDFThumbnailViewer {
selectedPages.has(lastDraggedOverIndex + 2))
)
) {
this._thumbnails[this._currentPageNumber - 1]?.toggleCurrent(
/* isCurrent = */ false
);
this._currentPageNumber = -1;
const newIndex = lastDraggedOverIndex + 1;
const pagesToMove = Array.from(selectedPages).sort((a, b) => a - b);
const pagesMapper = this.#pagesMapper;
const currentPageId = pagesMapper.getPageId(this._currentPageNumber);
const newCurrentPageId = pagesMapper.getPageId(
isNaN(this.#pageNumberToRemove)
? pagesToMove[0]
: this.#pageNumberToRemove
);
this.eventBus.dispatch("beforepagesedited", {
source: this,
pagesMapper,
});
let currentPageNumber = isNaN(this.#pageNumberToRemove)
? pagesToMove[0]
: this.#pageNumberToRemove;
pagesMapper.movePages(selectedPages, pagesToMove, newIndex);
this.#updateThumbnails();
this._currentPageNumber = pagesMapper.getPageNumber(currentPageId);
currentPageNumber = this.#updateThumbnails(currentPageNumber);
this.#computeThumbnailsPosition();
selectedPages.clear();
this.#pageNumberToRemove = NaN;
this.#updateMenuEntries();
const isIdentity = (this.#manageSaveAsButton.disabled =
!this.#pagesMapper.hasBeenAltered());
if (!isIdentity) {
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper,
index: newIndex,
pagesToMove,
});
}
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper,
type: "move",
});
const newCurrentPageNumber = pagesMapper.getPageNumber(newCurrentPageId);
setTimeout(() => {
this.linkService.goToPage(newCurrentPageNumber);
this.forceRendering();
this.linkService.goToPage(currentPageNumber);
}, 0);
}
@ -563,6 +600,121 @@ class PDFThumbnailViewer {
}
}
#clearSelection() {
for (const pageNumber of this.#selectedPages) {
this._thumbnails[pageNumber - 1].toggleSelected(false);
}
this.#selectedPages.clear();
}
#copyPages(clearSelection = true) {
const pageNumbersToCopy = (this.#copiedPageNumbers = Uint32Array.from(
this.#selectedPages
).sort((a, b) => a - b));
const pagesMapper = this.#pagesMapper;
pagesMapper.copyPages(pageNumbersToCopy);
this.#copiedThumbnails ||= new Map();
for (const pageNumber of pageNumbersToCopy) {
this.#copiedThumbnails.set(pageNumber, this._thumbnails[pageNumber - 1]);
}
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper,
pageNumbers: pageNumbersToCopy,
type: "copy",
});
if (clearSelection) {
this.#clearSelection();
}
for (const thumbnail of this._thumbnails) {
thumbnail.addPasteButton(this.#pastePages.bind(this));
}
this.container.classList.add("pasteMode");
this.#toggleMenuEntries(false);
}
#cutPages() {
this.#isCut = true;
this.#copyPages(false);
this.#deletePages(/* type = */ "cut");
}
#pastePages(index) {
this.container.classList.remove("pasteMode");
this.#toggleMenuEntries(true);
const pagesMapper = this.#pagesMapper;
let currentPageNumber = this.#copiedPageNumbers.includes(
this._currentPageNumber
)
? 0
: this._currentPageNumber;
pagesMapper.pastePages(index);
currentPageNumber = this.#updateThumbnails(currentPageNumber);
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper,
hasBeenCut: this.#isCut,
type: "paste",
});
this.#copiedThumbnails = null;
this.#isCut = false;
this.#updateMenuEntries();
setTimeout(() => {
this.forceRendering();
this.linkService.goToPage(currentPageNumber || 1);
}, 0);
}
#deletePages(type = "delete") {
const selectedPages = this.#selectedPages;
if (selectedPages.size === 0) {
return;
}
const pagesMapper = this.#pagesMapper;
let currentPageNumber = selectedPages.has(this._currentPageNumber)
? 0
: this._currentPageNumber;
const pagesToDelete = Uint32Array.from(selectedPages).sort((a, b) => a - b);
pagesMapper.deletePages(pagesToDelete);
currentPageNumber = this.#updateThumbnails(currentPageNumber);
selectedPages.clear();
this.#updateMenuEntries();
this.eventBus.dispatch("pagesedited", {
source: this,
pagesMapper,
pageNumbers: pagesToDelete,
type,
});
setTimeout(() => {
this.forceRendering();
this.linkService.goToPage(currentPageNumber || 1);
}, 0);
}
#updateMenuEntries() {
this.#manageSaveAsButton.disabled = !this.#pagesMapper.hasBeenAltered();
this.#manageDeleteButton.disabled =
this.#manageCopyButton.disabled =
this.#manageCutButton.disabled =
!this.#selectedPages?.size;
}
#toggleMenuEntries(enable) {
this.#manageSaveAsButton.disabled =
this.#manageDeleteButton.disabled =
this.#manageCopyButton.disabled =
this.#manageCutButton.disabled =
!enable;
}
#moveDraggedContainer(dx, dy) {
this.#draggedImageOffsetX += dx;
this.#draggedImageOffsetY += dy;
@ -714,11 +866,11 @@ class PDFThumbnailViewer {
stopEvent(e);
break;
case "Home":
this._thumbnails[0].image.focus();
this._thumbnails[0].imageContainer.focus();
stopEvent(e);
break;
case "End":
this._thumbnails.at(-1).image.focus();
this._thumbnails.at(-1).imageContainer.focus();
stopEvent(e);
break;
case "Enter":
@ -749,6 +901,7 @@ class PDFThumbnailViewer {
} else {
set.delete(pageNumber);
}
this.#updateMenuEntries();
}
#addDragListeners() {
@ -763,8 +916,9 @@ class PDFThumbnailViewer {
pointerId: dragPointerId,
} = e;
if (
this.#pagesMapper.copiedPageNumbers?.length > 0 ||
!isNaN(this.#lastDraggedOverIndex) ||
!draggedImage.classList.contains("thumbnailImage")
!draggedImage.classList.contains("thumbnailImageContainer")
) {
// We're already handling a drag, or the target is not draggable.
return;
@ -884,7 +1038,7 @@ class PDFThumbnailViewer {
#goToPage(e) {
const { target } = e;
if (target.classList.contains("thumbnailImage")) {
if (target.classList.contains("thumbnailImageContainer")) {
const pageNumber = parseInt(
target.parentElement.getAttribute("page-number"),
10
@ -943,7 +1097,7 @@ class PDFThumbnailViewer {
}
}
if (nextThumbnail) {
nextThumbnail.image.focus();
nextThumbnail.imageContainer.focus();
}
}

View File

@ -289,6 +289,8 @@ class PDFViewer {
#viewerAlert = null;
#copiedPageViews = null;
/**
* @param {PDFViewerOptions} options
*/
@ -1173,23 +1175,44 @@ class PDFViewer {
});
}
async onBeforePagesEdited({ pagesMapper }) {
await this._pagesCapability.promise;
this._currentPageId = pagesMapper.getPageId(this._currentPageNumber);
}
onPagesEdited({ pagesMapper, type, hasBeenCut, pageNumbers }) {
if (type === "copy") {
this.#copiedPageViews = new Map();
for (const pageNum of pageNumbers) {
this.#copiedPageViews.set(pageNum, this._pages[pageNum - 1]);
}
return;
}
onPagesEdited({ pagesMapper }) {
this._currentPageNumber = pagesMapper.getPageNumber(this._currentPageId);
const isCut = type === "cut";
if (isCut || type === "delete") {
for (const pageNum of pageNumbers) {
this._pages[pageNum - 1].deleteMe(isCut);
}
}
this._currentPageNumber = 0;
const prevPages = this._pages;
const newPages = (this._pages = []);
for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) {
const prevPageNumber = pagesMapper.getPrevPageNumber(i + 1) - 1;
if (prevPageNumber === -1) {
for (let i = 1, ii = pagesMapper.pagesNumber; i <= ii; i++) {
const prevPageNumber = pagesMapper.getPrevPageNumber(i);
if (prevPageNumber < 0) {
let page = this.#copiedPageViews.get(-prevPageNumber);
if (hasBeenCut) {
page.updatePageNumber(i);
} else {
page = page.clone(i);
}
newPages.push(page);
continue;
}
const page = prevPages[prevPageNumber];
newPages[i] = page;
page.updatePageNumber(i + 1);
const page = prevPages[prevPageNumber - 1];
newPages.push(page);
page.updatePageNumber(i);
}
if (!isCut) {
this.#copiedPageViews = null;
}
const viewerElement =
@ -1204,6 +1227,7 @@ class PDFViewer {
}
viewerElement.append(fragment);
}
setTimeout(() => {
this.forceRendering();
});

View File

@ -535,7 +535,7 @@
cursor: grabbing;
> .thumbnail {
> .thumbnailImage:hover {
> .thumbnailImageContainer:hover {
cursor: grabbing;
&:not([aria-current="page"]) {
@ -557,6 +557,35 @@
}
}
&.pasteMode {
> .thumbnail {
flex-direction: column;
> input {
display: none;
}
> .thumbnailPasteButton {
display: flex;
justify-content: center;
align-items: center;
border-radius: 16px;
min-height: 24px;
padding: 4px 16px;
font: menu;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
}
}
&:not(.pasteMode) > .thumbnail > .thumbnailPasteButton {
display: none;
}
> .thumbnail {
display: inline-flex;
justify-content: center;
@ -567,14 +596,19 @@
position: relative;
scroll-margin-top: 20px;
&:not(.isDragging)::after {
.thumbnailPasteButton {
padding: 8px 0;
text-align: center;
}
&:not(.isDragging) > .thumbnailImageContainer::after {
content: attr(page-number);
border-radius: 8px;
background-color: var(--image-page-number-bg);
color: var(--image-page-number-fg);
position: absolute;
bottom: 5px;
inset-inline-end: calc(var(--thumbnail-width) / 2);
inset-inline-end: 50%;
min-width: 32px;
height: 16px;
text-align: center;
@ -590,7 +624,8 @@
user-select: none;
}
&:has([aria-current="page"]):not(.isDragging)::after {
&:has([aria-current="page"]):not(.isDragging)
> .thumbnailImageContainer::after {
background-color: var(--image-current-page-number-bg);
color: var(--image-current-page-number-fg);
}
@ -603,7 +638,7 @@
margin: 0;
}
> .thumbnailImage {
> .thumbnailImageContainer {
--thumbnail-dragging-scale: 1.4;
width: var(--thumbnail-width);
@ -613,6 +648,17 @@
box-sizing: content-box;
outline: var(--image-outline);
user-select: none;
position: relative;
img {
width: 100%;
height: 100%;
border: inherit;
border-radius: inherit;
outline: none;
user-select: none;
pointer-events: none;
}
&.missingThumbnailImage {
content-visibility: hidden;
@ -658,7 +704,7 @@
&.multiple {
box-shadow: var(--image-multiple-dragging-shadow);
> img {
> .thumbnailImageContainer {
position: absolute;
top: 0;
left: 0;