Skip to content

Commit 67673ea

Browse files
authored
Merge pull request #20559 from calixteman/new_sidebar2
Add the possibility to drag & drop some thumbnails in the pages view (bug 2009573)
2 parents cbcb627 + 5e89981 commit 67673ea

13 files changed

Lines changed: 1163 additions & 30 deletions

extensions/chromium/preferences_schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979
"type": "boolean",
8080
"default": false
8181
},
82+
"enableSplitMerge": {
83+
"type": "boolean",
84+
"default": false
85+
},
8286
"enableUpdatedAddImage": {
8387
"type": "boolean",
8488
"default": false

test/integration/jasmine-boot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ async function runTests(results) {
3737
"freetext_editor_spec.mjs",
3838
"highlight_editor_spec.mjs",
3939
"ink_editor_spec.mjs",
40+
"reorganize_pages_spec.mjs",
4041
"scripting_spec.mjs",
4142
"signature_editor_spec.mjs",
4243
"stamp_editor_spec.mjs",
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/* Copyright 2026 Mozilla Foundation
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License.
5+
* You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import {
17+
awaitPromise,
18+
closePages,
19+
createPromise,
20+
dragAndDrop,
21+
getRect,
22+
getThumbnailSelector,
23+
loadAndWait,
24+
waitForDOMMutation,
25+
} from "./test_utils.mjs";
26+
27+
async function waitForThumbnailVisible(page, pageNums) {
28+
await page.click("#viewsManagerToggleButton");
29+
30+
const thumbSelector = "#thumbnailsView .thumbnailImage";
31+
await page.waitForSelector(thumbSelector, { visible: true });
32+
if (!pageNums) {
33+
return null;
34+
}
35+
if (!Array.isArray(pageNums)) {
36+
pageNums = [pageNums];
37+
}
38+
return Promise.all(
39+
pageNums.map(pageNum =>
40+
page.waitForSelector(getThumbnailSelector(pageNum), { visible: true })
41+
)
42+
);
43+
}
44+
45+
function waitForPagesEdited(page) {
46+
return createPromise(page, resolve => {
47+
window.PDFViewerApplication.eventBus.on(
48+
"pagesedited",
49+
({ pagesMapper }) => {
50+
resolve(Array.from(pagesMapper.getMapping()));
51+
},
52+
{
53+
once: true,
54+
}
55+
);
56+
});
57+
}
58+
59+
describe("Reorganize Pages View", () => {
60+
describe("Drag & Drop", () => {
61+
let pages;
62+
63+
beforeEach(async () => {
64+
pages = await loadAndWait(
65+
"page_with_number.pdf",
66+
"#viewsManagerToggleButton",
67+
"page-fit",
68+
null,
69+
{ enableSplitMerge: true }
70+
);
71+
});
72+
73+
afterEach(async () => {
74+
await closePages(pages);
75+
});
76+
77+
it("should show a drag marker when dragging a thumbnail", async () => {
78+
await Promise.all(
79+
pages.map(async ([browserName, page]) => {
80+
await waitForThumbnailVisible(page, 1);
81+
const rect1 = await getRect(page, getThumbnailSelector(1));
82+
const rect2 = await getRect(page, getThumbnailSelector(2));
83+
84+
const handleAddedMarker = await waitForDOMMutation(
85+
page,
86+
mutationList => {
87+
for (const mutation of mutationList) {
88+
if (mutation.type !== "childList") {
89+
continue;
90+
}
91+
for (const node of mutation.addedNodes) {
92+
if (node.classList.contains("dragMarker")) {
93+
return true;
94+
}
95+
}
96+
}
97+
return false;
98+
}
99+
);
100+
const handleRemovedMarker = await waitForDOMMutation(
101+
page,
102+
mutationList => {
103+
for (const mutation of mutationList) {
104+
if (mutation.type !== "childList") {
105+
continue;
106+
}
107+
for (const node of mutation.removedNodes) {
108+
if (node.classList.contains("dragMarker")) {
109+
return true;
110+
}
111+
}
112+
}
113+
return false;
114+
}
115+
);
116+
const dndPromise = dragAndDrop(
117+
page,
118+
getThumbnailSelector(1),
119+
[[0, rect2.y - rect1.y + rect2.height / 2]],
120+
10
121+
);
122+
await dndPromise;
123+
await awaitPromise(handleAddedMarker);
124+
await awaitPromise(handleRemovedMarker);
125+
})
126+
);
127+
});
128+
129+
it("should reorder thumbnails after dropping", async () => {
130+
await Promise.all(
131+
pages.map(async ([browserName, page]) => {
132+
await waitForThumbnailVisible(page, 1);
133+
const rect1 = await getRect(page, getThumbnailSelector(1));
134+
const rect2 = await getRect(page, getThumbnailSelector(2));
135+
136+
const handlePagesEdited = await waitForPagesEdited(page);
137+
await dragAndDrop(
138+
page,
139+
getThumbnailSelector(1),
140+
[[0, rect2.y - rect1.y + rect2.height / 2]],
141+
10
142+
);
143+
const pagesMapping = await awaitPromise(handlePagesEdited);
144+
expect(pagesMapping)
145+
.withContext(`In ${browserName}`)
146+
.toEqual([
147+
2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
148+
]);
149+
})
150+
);
151+
});
152+
153+
it("should reorder thumbnails after dropping at position 0", async () => {
154+
await Promise.all(
155+
pages.map(async ([browserName, page]) => {
156+
await waitForThumbnailVisible(page, 1);
157+
const rect1 = await getRect(page, getThumbnailSelector(1));
158+
const rect2 = await getRect(page, getThumbnailSelector(2));
159+
160+
const handlePagesEdited = await waitForPagesEdited(page);
161+
await dragAndDrop(
162+
page,
163+
getThumbnailSelector(2),
164+
[[0, rect1.y - rect2.y - rect1.height]],
165+
10
166+
);
167+
const pagesMapping = await awaitPromise(handlePagesEdited);
168+
expect(pagesMapping)
169+
.withContext(`In ${browserName}`)
170+
.toEqual([
171+
2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
172+
]);
173+
})
174+
);
175+
});
176+
177+
it("should reorder thumbnails after dropping two adjacent pages", async () => {
178+
await Promise.all(
179+
pages.map(async ([browserName, page]) => {
180+
await waitForThumbnailVisible(page, 1);
181+
const rect2 = await getRect(page, getThumbnailSelector(2));
182+
const rect4 = await getRect(page, getThumbnailSelector(4));
183+
await page.click(`.thumbnail:has(${getThumbnailSelector(1)}) input`);
184+
185+
const handlePagesEdited = await waitForPagesEdited(page);
186+
await dragAndDrop(
187+
page,
188+
getThumbnailSelector(2),
189+
[[0, rect4.y - rect2.y]],
190+
10
191+
);
192+
const pagesMapping = await awaitPromise(handlePagesEdited);
193+
expect(pagesMapping)
194+
.withContext(`In ${browserName}`)
195+
.toEqual([
196+
3, 4, 1, 2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
197+
]);
198+
})
199+
);
200+
});
201+
202+
it("should reorder thumbnails after dropping two non-adjacent pages", async () => {
203+
await Promise.all(
204+
pages.map(async ([browserName, page]) => {
205+
await waitForThumbnailVisible(page, 1);
206+
const rect1 = await getRect(page, getThumbnailSelector(1));
207+
const rect2 = await getRect(page, getThumbnailSelector(2));
208+
await (await page.$(".thumbnail[page-id='14'")).scrollIntoView();
209+
await page.waitForSelector(getThumbnailSelector(14), {
210+
visible: true,
211+
});
212+
await page.click(`.thumbnail:has(${getThumbnailSelector(14)}) input`);
213+
await (await page.$(".thumbnail[page-id='1'")).scrollIntoView();
214+
await page.waitForSelector(getThumbnailSelector(1), {
215+
visible: true,
216+
});
217+
218+
const handlePagesEdited = await waitForPagesEdited(page);
219+
await dragAndDrop(
220+
page,
221+
getThumbnailSelector(1),
222+
[[0, rect2.y - rect1.y + rect2.height / 2]],
223+
10
224+
);
225+
const pagesMapping = await awaitPromise(handlePagesEdited);
226+
expect(pagesMapping)
227+
.withContext(`In ${browserName}`)
228+
.toEqual([
229+
2, 1, 14, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17,
230+
]);
231+
})
232+
);
233+
});
234+
});
235+
});

test/integration/test_utils.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ async function waitForSandboxTrip(page) {
158158
await awaitPromise(handle);
159159
}
160160

161+
async function waitForDOMMutation(page, callback) {
162+
return page.evaluateHandle(
163+
cb => [
164+
new Promise(resolve => {
165+
const mutationObserver = new MutationObserver(mutationList => {
166+
// eslint-disable-next-line no-eval
167+
if (eval(`(${cb})`)(mutationList)) {
168+
mutationObserver.disconnect();
169+
resolve();
170+
}
171+
});
172+
mutationObserver.observe(document, { childList: true, subtree: true });
173+
}),
174+
],
175+
callback.toString()
176+
);
177+
}
178+
161179
function waitForTimeout(milliseconds) {
162180
/**
163181
* Wait for the given number of milliseconds.
@@ -234,6 +252,10 @@ function getAnnotationSelector(id) {
234252
return `[data-annotation-id="${id}"]`;
235253
}
236254

255+
function getThumbnailSelector(pageNumber) {
256+
return `.thumbnailImage[data-l10n-args='{"page":${pageNumber}}']`;
257+
}
258+
237259
async function getSpanRectFromText(page, pageNumber, text) {
238260
await page.waitForSelector(
239261
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
@@ -957,6 +979,7 @@ export {
957979
getSelector,
958980
getSerialized,
959981
getSpanRectFromText,
982+
getThumbnailSelector,
960983
getXY,
961984
highlightSpan,
962985
isCanvasMonochrome,
@@ -991,6 +1014,7 @@ export {
9911014
waitAndClick,
9921015
waitForAnnotationEditorLayer,
9931016
waitForAnnotationModeChanged,
1017+
waitForDOMMutation,
9941018
waitForEntryInStorage,
9951019
waitForEvent,
9961020
waitForNoElement,

test/pdfs/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,3 +868,4 @@
868868
!bitmap.pdf
869869
!bomb_giant.pdf
870870
!bug2009627.pdf
871+
!page_with_number.pdf

test/pdfs/page_with_number.pdf

34.7 KB
Binary file not shown.

web/app.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ const PDFViewerApplication = {
377377
enableFakeMLManager: x => x === "true",
378378
enableGuessAltText: x => x === "true",
379379
enablePermissions: x => x === "true",
380+
enableSplitMerge: x => x === "true",
380381
enableUpdatedAddImage: x => x === "true",
381382
highlightEditorColors: x => x,
382383
maxCanvasPixels: x => parseInt(x),
@@ -602,6 +603,7 @@ const PDFViewerApplication = {
602603
pageColors,
603604
abortSignal,
604605
enableHWA,
606+
enableSplitMerge: AppOptions.get("enableSplitMerge"),
605607
});
606608
renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer);
607609
}
@@ -2185,6 +2187,12 @@ const PDFViewerApplication = {
21852187
opts
21862188
);
21872189
}
2190+
eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts);
2191+
eventBus._on(
2192+
"beforepagesedited",
2193+
this.onBeforePagesEdited.bind(this),
2194+
opts
2195+
);
21882196
},
21892197

21902198
bindWindowEvents() {
@@ -2359,6 +2367,14 @@ const PDFViewerApplication = {
23592367
await Promise.all([this.l10n?.destroy(), this.close()]);
23602368
},
23612369

2370+
onBeforePagesEdited(data) {
2371+
this.pdfViewer.onBeforePagesEdited(data);
2372+
},
2373+
2374+
onPagesEdited(data) {
2375+
this.pdfViewer.onPagesEdited(data);
2376+
},
2377+
23622378
_accumulateTicks(ticks, prop) {
23632379
// If the direction changed, reset the accumulated ticks.
23642380
if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) {

web/app_options.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ const defaultOptions = {
279279
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
280280
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
281281
},
282+
enableSplitMerge: {
283+
/** @type {boolean} */
284+
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
285+
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
286+
},
282287
enableUpdatedAddImage: {
283288
// We'll probably want to make some experiments before enabling this
284289
// in Firefox release, but it has to be temporary.

0 commit comments

Comments
 (0)