mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-08-02 04:17:24 +02:00
Add the possibility to drag & drop some thumbnails in the pages view
The goal is to be able to reorganize the pages in a pdf.
This commit is contained in:
parent
6612d7afaf
commit
5e89981282
@ -79,6 +79,10 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
},
|
},
|
||||||
|
"enableSplitMerge": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
"enableUpdatedAddImage": {
|
"enableUpdatedAddImage": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
|
|||||||
@ -37,6 +37,7 @@ async function runTests(results) {
|
|||||||
"freetext_editor_spec.mjs",
|
"freetext_editor_spec.mjs",
|
||||||
"highlight_editor_spec.mjs",
|
"highlight_editor_spec.mjs",
|
||||||
"ink_editor_spec.mjs",
|
"ink_editor_spec.mjs",
|
||||||
|
"reorganize_pages_spec.mjs",
|
||||||
"scripting_spec.mjs",
|
"scripting_spec.mjs",
|
||||||
"signature_editor_spec.mjs",
|
"signature_editor_spec.mjs",
|
||||||
"stamp_editor_spec.mjs",
|
"stamp_editor_spec.mjs",
|
||||||
|
|||||||
235
test/integration/reorganize_pages_spec.mjs
Normal file
235
test/integration/reorganize_pages_spec.mjs
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
awaitPromise,
|
||||||
|
closePages,
|
||||||
|
createPromise,
|
||||||
|
dragAndDrop,
|
||||||
|
getRect,
|
||||||
|
getThumbnailSelector,
|
||||||
|
loadAndWait,
|
||||||
|
waitForDOMMutation,
|
||||||
|
} from "./test_utils.mjs";
|
||||||
|
|
||||||
|
async function waitForThumbnailVisible(page, pageNums) {
|
||||||
|
await page.click("#viewsManagerToggleButton");
|
||||||
|
|
||||||
|
const thumbSelector = "#thumbnailsView .thumbnailImage";
|
||||||
|
await page.waitForSelector(thumbSelector, { visible: true });
|
||||||
|
if (!pageNums) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(pageNums)) {
|
||||||
|
pageNums = [pageNums];
|
||||||
|
}
|
||||||
|
return Promise.all(
|
||||||
|
pageNums.map(pageNum =>
|
||||||
|
page.waitForSelector(getThumbnailSelector(pageNum), { visible: true })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForPagesEdited(page) {
|
||||||
|
return createPromise(page, resolve => {
|
||||||
|
window.PDFViewerApplication.eventBus.on(
|
||||||
|
"pagesedited",
|
||||||
|
({ pagesMapper }) => {
|
||||||
|
resolve(Array.from(pagesMapper.getMapping()));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
once: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Reorganize Pages View", () => {
|
||||||
|
describe("Drag & Drop", () => {
|
||||||
|
let pages;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
pages = await loadAndWait(
|
||||||
|
"page_with_number.pdf",
|
||||||
|
"#viewsManagerToggleButton",
|
||||||
|
"page-fit",
|
||||||
|
null,
|
||||||
|
{ enableSplitMerge: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await closePages(pages);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show a drag marker when dragging a thumbnail", async () => {
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(async ([browserName, page]) => {
|
||||||
|
await waitForThumbnailVisible(page, 1);
|
||||||
|
const rect1 = await getRect(page, getThumbnailSelector(1));
|
||||||
|
const rect2 = await getRect(page, getThumbnailSelector(2));
|
||||||
|
|
||||||
|
const handleAddedMarker = await waitForDOMMutation(
|
||||||
|
page,
|
||||||
|
mutationList => {
|
||||||
|
for (const mutation of mutationList) {
|
||||||
|
if (mutation.type !== "childList") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (node.classList.contains("dragMarker")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const handleRemovedMarker = await waitForDOMMutation(
|
||||||
|
page,
|
||||||
|
mutationList => {
|
||||||
|
for (const mutation of mutationList) {
|
||||||
|
if (mutation.type !== "childList") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const node of mutation.removedNodes) {
|
||||||
|
if (node.classList.contains("dragMarker")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const dndPromise = dragAndDrop(
|
||||||
|
page,
|
||||||
|
getThumbnailSelector(1),
|
||||||
|
[[0, rect2.y - rect1.y + rect2.height / 2]],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
await dndPromise;
|
||||||
|
await awaitPromise(handleAddedMarker);
|
||||||
|
await awaitPromise(handleRemovedMarker);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reorder thumbnails after dropping", async () => {
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(async ([browserName, page]) => {
|
||||||
|
await waitForThumbnailVisible(page, 1);
|
||||||
|
const rect1 = await getRect(page, getThumbnailSelector(1));
|
||||||
|
const rect2 = await getRect(page, getThumbnailSelector(2));
|
||||||
|
|
||||||
|
const handlePagesEdited = await waitForPagesEdited(page);
|
||||||
|
await dragAndDrop(
|
||||||
|
page,
|
||||||
|
getThumbnailSelector(1),
|
||||||
|
[[0, rect2.y - rect1.y + rect2.height / 2]],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const pagesMapping = await awaitPromise(handlePagesEdited);
|
||||||
|
expect(pagesMapping)
|
||||||
|
.withContext(`In ${browserName}`)
|
||||||
|
.toEqual([
|
||||||
|
2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reorder thumbnails after dropping at position 0", async () => {
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(async ([browserName, page]) => {
|
||||||
|
await waitForThumbnailVisible(page, 1);
|
||||||
|
const rect1 = await getRect(page, getThumbnailSelector(1));
|
||||||
|
const rect2 = await getRect(page, getThumbnailSelector(2));
|
||||||
|
|
||||||
|
const handlePagesEdited = await waitForPagesEdited(page);
|
||||||
|
await dragAndDrop(
|
||||||
|
page,
|
||||||
|
getThumbnailSelector(2),
|
||||||
|
[[0, rect1.y - rect2.y - rect1.height]],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const pagesMapping = await awaitPromise(handlePagesEdited);
|
||||||
|
expect(pagesMapping)
|
||||||
|
.withContext(`In ${browserName}`)
|
||||||
|
.toEqual([
|
||||||
|
2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reorder thumbnails after dropping two adjacent pages", async () => {
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(async ([browserName, page]) => {
|
||||||
|
await waitForThumbnailVisible(page, 1);
|
||||||
|
const rect2 = await getRect(page, getThumbnailSelector(2));
|
||||||
|
const rect4 = await getRect(page, getThumbnailSelector(4));
|
||||||
|
await page.click(`.thumbnail:has(${getThumbnailSelector(1)}) input`);
|
||||||
|
|
||||||
|
const handlePagesEdited = await waitForPagesEdited(page);
|
||||||
|
await dragAndDrop(
|
||||||
|
page,
|
||||||
|
getThumbnailSelector(2),
|
||||||
|
[[0, rect4.y - rect2.y]],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const pagesMapping = await awaitPromise(handlePagesEdited);
|
||||||
|
expect(pagesMapping)
|
||||||
|
.withContext(`In ${browserName}`)
|
||||||
|
.toEqual([
|
||||||
|
3, 4, 1, 2, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reorder thumbnails after dropping two non-adjacent pages", async () => {
|
||||||
|
await Promise.all(
|
||||||
|
pages.map(async ([browserName, page]) => {
|
||||||
|
await waitForThumbnailVisible(page, 1);
|
||||||
|
const rect1 = await getRect(page, getThumbnailSelector(1));
|
||||||
|
const rect2 = await getRect(page, getThumbnailSelector(2));
|
||||||
|
await (await page.$(".thumbnail[page-id='14'")).scrollIntoView();
|
||||||
|
await page.waitForSelector(getThumbnailSelector(14), {
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
await page.click(`.thumbnail:has(${getThumbnailSelector(14)}) input`);
|
||||||
|
await (await page.$(".thumbnail[page-id='1'")).scrollIntoView();
|
||||||
|
await page.waitForSelector(getThumbnailSelector(1), {
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePagesEdited = await waitForPagesEdited(page);
|
||||||
|
await dragAndDrop(
|
||||||
|
page,
|
||||||
|
getThumbnailSelector(1),
|
||||||
|
[[0, rect2.y - rect1.y + rect2.height / 2]],
|
||||||
|
10
|
||||||
|
);
|
||||||
|
const pagesMapping = await awaitPromise(handlePagesEdited);
|
||||||
|
expect(pagesMapping)
|
||||||
|
.withContext(`In ${browserName}`)
|
||||||
|
.toEqual([
|
||||||
|
2, 1, 14, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -158,6 +158,24 @@ async function waitForSandboxTrip(page) {
|
|||||||
await awaitPromise(handle);
|
await awaitPromise(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForDOMMutation(page, callback) {
|
||||||
|
return page.evaluateHandle(
|
||||||
|
cb => [
|
||||||
|
new Promise(resolve => {
|
||||||
|
const mutationObserver = new MutationObserver(mutationList => {
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
if (eval(`(${cb})`)(mutationList)) {
|
||||||
|
mutationObserver.disconnect();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mutationObserver.observe(document, { childList: true, subtree: true });
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callback.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function waitForTimeout(milliseconds) {
|
function waitForTimeout(milliseconds) {
|
||||||
/**
|
/**
|
||||||
* Wait for the given number of milliseconds.
|
* Wait for the given number of milliseconds.
|
||||||
@ -234,6 +252,10 @@ function getAnnotationSelector(id) {
|
|||||||
return `[data-annotation-id="${id}"]`;
|
return `[data-annotation-id="${id}"]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getThumbnailSelector(pageNumber) {
|
||||||
|
return `.thumbnailImage[data-l10n-args='{"page":${pageNumber}}']`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getSpanRectFromText(page, pageNumber, text) {
|
async function getSpanRectFromText(page, pageNumber, text) {
|
||||||
await page.waitForSelector(
|
await page.waitForSelector(
|
||||||
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
|
`.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent`
|
||||||
@ -957,6 +979,7 @@ export {
|
|||||||
getSelector,
|
getSelector,
|
||||||
getSerialized,
|
getSerialized,
|
||||||
getSpanRectFromText,
|
getSpanRectFromText,
|
||||||
|
getThumbnailSelector,
|
||||||
getXY,
|
getXY,
|
||||||
highlightSpan,
|
highlightSpan,
|
||||||
isCanvasMonochrome,
|
isCanvasMonochrome,
|
||||||
@ -991,6 +1014,7 @@ export {
|
|||||||
waitAndClick,
|
waitAndClick,
|
||||||
waitForAnnotationEditorLayer,
|
waitForAnnotationEditorLayer,
|
||||||
waitForAnnotationModeChanged,
|
waitForAnnotationModeChanged,
|
||||||
|
waitForDOMMutation,
|
||||||
waitForEntryInStorage,
|
waitForEntryInStorage,
|
||||||
waitForEvent,
|
waitForEvent,
|
||||||
waitForNoElement,
|
waitForNoElement,
|
||||||
|
|||||||
1
test/pdfs/.gitignore
vendored
1
test/pdfs/.gitignore
vendored
@ -868,3 +868,4 @@
|
|||||||
!bitmap.pdf
|
!bitmap.pdf
|
||||||
!bomb_giant.pdf
|
!bomb_giant.pdf
|
||||||
!bug2009627.pdf
|
!bug2009627.pdf
|
||||||
|
!page_with_number.pdf
|
||||||
|
|||||||
BIN
test/pdfs/page_with_number.pdf
Executable file
BIN
test/pdfs/page_with_number.pdf
Executable file
Binary file not shown.
16
web/app.js
16
web/app.js
@ -377,6 +377,7 @@ const PDFViewerApplication = {
|
|||||||
enableFakeMLManager: x => x === "true",
|
enableFakeMLManager: x => x === "true",
|
||||||
enableGuessAltText: x => x === "true",
|
enableGuessAltText: x => x === "true",
|
||||||
enablePermissions: x => x === "true",
|
enablePermissions: x => x === "true",
|
||||||
|
enableSplitMerge: x => x === "true",
|
||||||
enableUpdatedAddImage: x => x === "true",
|
enableUpdatedAddImage: x => x === "true",
|
||||||
highlightEditorColors: x => x,
|
highlightEditorColors: x => x,
|
||||||
maxCanvasPixels: x => parseInt(x),
|
maxCanvasPixels: x => parseInt(x),
|
||||||
@ -602,6 +603,7 @@ const PDFViewerApplication = {
|
|||||||
pageColors,
|
pageColors,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
enableHWA,
|
enableHWA,
|
||||||
|
enableSplitMerge: AppOptions.get("enableSplitMerge"),
|
||||||
});
|
});
|
||||||
renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer);
|
renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer);
|
||||||
}
|
}
|
||||||
@ -2185,6 +2187,12 @@ const PDFViewerApplication = {
|
|||||||
opts
|
opts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts);
|
||||||
|
eventBus._on(
|
||||||
|
"beforepagesedited",
|
||||||
|
this.onBeforePagesEdited.bind(this),
|
||||||
|
opts
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
bindWindowEvents() {
|
bindWindowEvents() {
|
||||||
@ -2359,6 +2367,14 @@ const PDFViewerApplication = {
|
|||||||
await Promise.all([this.l10n?.destroy(), this.close()]);
|
await Promise.all([this.l10n?.destroy(), this.close()]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onBeforePagesEdited(data) {
|
||||||
|
this.pdfViewer.onBeforePagesEdited(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
onPagesEdited(data) {
|
||||||
|
this.pdfViewer.onPagesEdited(data);
|
||||||
|
},
|
||||||
|
|
||||||
_accumulateTicks(ticks, prop) {
|
_accumulateTicks(ticks, prop) {
|
||||||
// If the direction changed, reset the accumulated ticks.
|
// If the direction changed, reset the accumulated ticks.
|
||||||
if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) {
|
if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) {
|
||||||
|
|||||||
@ -279,6 +279,11 @@ const defaultOptions = {
|
|||||||
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||||
},
|
},
|
||||||
|
enableSplitMerge: {
|
||||||
|
/** @type {boolean} */
|
||||||
|
value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"),
|
||||||
|
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||||
|
},
|
||||||
enableUpdatedAddImage: {
|
enableUpdatedAddImage: {
|
||||||
// We'll probably want to make some experiments before enabling this
|
// We'll probably want to make some experiments before enabling this
|
||||||
// in Firefox release, but it has to be temporary.
|
// in Firefox release, but it has to be temporary.
|
||||||
|
|||||||
@ -97,6 +97,7 @@ class PDFThumbnailView {
|
|||||||
maxCanvasPixels,
|
maxCanvasPixels,
|
||||||
maxCanvasDim,
|
maxCanvasDim,
|
||||||
pageColors,
|
pageColors,
|
||||||
|
enableSplitMerge = false,
|
||||||
}) {
|
}) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.renderingId = "thumbnail" + id;
|
this.renderingId = "thumbnail" + id;
|
||||||
@ -118,22 +119,28 @@ class PDFThumbnailView {
|
|||||||
this.renderTask = null;
|
this.renderTask = null;
|
||||||
this.renderingState = RenderingStates.INITIAL;
|
this.renderingState = RenderingStates.INITIAL;
|
||||||
this.resume = null;
|
this.resume = null;
|
||||||
|
this.placeholder = null;
|
||||||
|
|
||||||
const imageContainer = (this.div = document.createElement("div"));
|
const imageContainer = (this.div = document.createElement("div"));
|
||||||
imageContainer.className = "thumbnail";
|
imageContainer.className = "thumbnail";
|
||||||
imageContainer.setAttribute("page-number", this.#pageNumber);
|
imageContainer.setAttribute("page-number", id);
|
||||||
|
imageContainer.setAttribute("page-id", id);
|
||||||
|
|
||||||
const checkbox = (this.checkbox = document.createElement("input"));
|
if (enableSplitMerge) {
|
||||||
checkbox.type = "checkbox";
|
const checkbox = (this.checkbox = document.createElement("input"));
|
||||||
checkbox.tabIndex = -1;
|
checkbox.type = "checkbox";
|
||||||
|
checkbox.tabIndex = -1;
|
||||||
|
imageContainer.append(checkbox);
|
||||||
|
}
|
||||||
|
|
||||||
const image = (this.image = document.createElement("img"));
|
const image = (this.image = document.createElement("img"));
|
||||||
image.classList.add("thumbnailImage", "missingThumbnailImage");
|
image.classList.add("thumbnailImage", "missingThumbnailImage");
|
||||||
image.role = "button";
|
image.role = "button";
|
||||||
image.tabIndex = -1;
|
image.tabIndex = -1;
|
||||||
|
image.draggable = false;
|
||||||
this.#updateDims();
|
this.#updateDims();
|
||||||
|
|
||||||
imageContainer.append(checkbox, image);
|
imageContainer.append(image);
|
||||||
container.append(imageContainer);
|
container.append(imageContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -440,10 +447,6 @@ class PDFThumbnailView {
|
|||||||
return JSON.stringify({ page: this.pageLabel ?? this.id });
|
return JSON.stringify({ page: this.pageLabel ?? this.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
get #pageNumber() {
|
|
||||||
return this.pageLabel ?? this.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string|null} label
|
* @param {string|null} label
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -21,12 +21,14 @@
|
|||||||
/** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */
|
/** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
binarySearchFirstItem,
|
||||||
getVisibleElements,
|
getVisibleElements,
|
||||||
isValidRotation,
|
isValidRotation,
|
||||||
|
PagesMapper,
|
||||||
RenderingStates,
|
RenderingStates,
|
||||||
watchScroll,
|
watchScroll,
|
||||||
} from "./ui_utils.js";
|
} from "./ui_utils.js";
|
||||||
import { MathClamp, stopEvent } from "pdfjs-lib";
|
import { MathClamp, noContextMenu, stopEvent } from "pdfjs-lib";
|
||||||
import { PDFThumbnailView } from "./pdf_thumbnail_view.js";
|
import { PDFThumbnailView } from "./pdf_thumbnail_view.js";
|
||||||
|
|
||||||
const SCROLL_OPTIONS = {
|
const SCROLL_OPTIONS = {
|
||||||
@ -36,6 +38,14 @@ const SCROLL_OPTIONS = {
|
|||||||
container: "nearest",
|
container: "nearest",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// This value is based on the one used in Firefox.
|
||||||
|
// See
|
||||||
|
// https://searchfox.org/firefox-main/rev/04cf27582307a9c351e991c740828d54cf786b76/dom/events/EventStateManager.cpp#2675-2698
|
||||||
|
// This threshold is used to distinguish between a click and a drag.
|
||||||
|
const DRAG_THRESHOLD_IN_PIXELS = 5;
|
||||||
|
const PIXELS_TO_SCROLL_WHEN_DRAGGING = 20;
|
||||||
|
const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} PDFThumbnailViewerOptions
|
* @typedef {Object} PDFThumbnailViewerOptions
|
||||||
* @property {HTMLDivElement} container - The container for the thumbnail
|
* @property {HTMLDivElement} container - The container for the thumbnail
|
||||||
@ -56,12 +66,52 @@ const SCROLL_OPTIONS = {
|
|||||||
* events.
|
* events.
|
||||||
* @property {boolean} [enableHWA] - Enables hardware acceleration for
|
* @property {boolean} [enableHWA] - Enables hardware acceleration for
|
||||||
* rendering. The default value is `false`.
|
* rendering. The default value is `false`.
|
||||||
|
* @property {boolean} [enableSplitMerge] - Enables split and merge features.
|
||||||
|
* The default value is `false`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Viewer control to display thumbnails for pages in a PDF document.
|
* Viewer control to display thumbnails for pages in a PDF document.
|
||||||
*/
|
*/
|
||||||
class PDFThumbnailViewer {
|
class PDFThumbnailViewer {
|
||||||
|
static #draggingScaleFactor = 0;
|
||||||
|
|
||||||
|
#enableSplitMerge = false;
|
||||||
|
|
||||||
|
#dragAC = null;
|
||||||
|
|
||||||
|
#draggedContainer = null;
|
||||||
|
|
||||||
|
#thumbnailsPositions = null;
|
||||||
|
|
||||||
|
#lastDraggedOverIndex = NaN;
|
||||||
|
|
||||||
|
#selectedPages = null;
|
||||||
|
|
||||||
|
#draggedImageX = 0;
|
||||||
|
|
||||||
|
#draggedImageY = 0;
|
||||||
|
|
||||||
|
#draggedImageWidth = 0;
|
||||||
|
|
||||||
|
#draggedImageHeight = 0;
|
||||||
|
|
||||||
|
#draggedImageOffsetX = 0;
|
||||||
|
|
||||||
|
#draggedImageOffsetY = 0;
|
||||||
|
|
||||||
|
#dragMarker = null;
|
||||||
|
|
||||||
|
#pageNumberToRemove = NaN;
|
||||||
|
|
||||||
|
#currentScrollBottom = 0;
|
||||||
|
|
||||||
|
#currentScrollTop = 0;
|
||||||
|
|
||||||
|
#pagesMapper = PagesMapper.instance;
|
||||||
|
|
||||||
|
#originalThumbnails = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {PDFThumbnailViewerOptions} options
|
* @param {PDFThumbnailViewerOptions} options
|
||||||
*/
|
*/
|
||||||
@ -75,6 +125,7 @@ class PDFThumbnailViewer {
|
|||||||
pageColors,
|
pageColors,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
enableHWA,
|
enableHWA,
|
||||||
|
enableSplitMerge,
|
||||||
}) {
|
}) {
|
||||||
this.scrollableContainer = container.parentElement;
|
this.scrollableContainer = container.parentElement;
|
||||||
this.container = container;
|
this.container = container;
|
||||||
@ -85,6 +136,7 @@ class PDFThumbnailViewer {
|
|||||||
this.maxCanvasDim = maxCanvasDim;
|
this.maxCanvasDim = maxCanvasDim;
|
||||||
this.pageColors = pageColors || null;
|
this.pageColors = pageColors || null;
|
||||||
this.enableHWA = enableHWA || false;
|
this.enableHWA = enableHWA || false;
|
||||||
|
this.#enableSplitMerge = enableSplitMerge || false;
|
||||||
|
|
||||||
this.scroll = watchScroll(
|
this.scroll = watchScroll(
|
||||||
this.scrollableContainer,
|
this.scrollableContainer,
|
||||||
@ -120,7 +172,6 @@ class PDFThumbnailViewer {
|
|||||||
console.error('scrollThumbnailIntoView: Invalid "pageNumber" parameter.');
|
console.error('scrollThumbnailIntoView: Invalid "pageNumber" parameter.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageNumber !== this._currentPageNumber) {
|
if (pageNumber !== this._currentPageNumber) {
|
||||||
const prevThumbnailView = this._thumbnails[this._currentPageNumber - 1];
|
const prevThumbnailView = this._thumbnails[this._currentPageNumber - 1];
|
||||||
prevThumbnailView.toggleCurrent(/* isCurrent = */ false);
|
prevThumbnailView.toggleCurrent(/* isCurrent = */ false);
|
||||||
@ -132,11 +183,15 @@ class PDFThumbnailViewer {
|
|||||||
// If the thumbnail isn't currently visible, scroll it into view.
|
// If the thumbnail isn't currently visible, scroll it into view.
|
||||||
if (views.length > 0) {
|
if (views.length > 0) {
|
||||||
let shouldScroll = false;
|
let shouldScroll = false;
|
||||||
if (pageNumber <= first.id || pageNumber >= last.id) {
|
if (
|
||||||
|
pageNumber <= this.#pagesMapper.getPageNumber(first.id) ||
|
||||||
|
pageNumber >= this.#pagesMapper.getPageNumber(last.id)
|
||||||
|
) {
|
||||||
shouldScroll = true;
|
shouldScroll = true;
|
||||||
} else {
|
} else {
|
||||||
for (const { id, percent } of views) {
|
for (const { id, percent } of views) {
|
||||||
if (id !== pageNumber) {
|
const mappedPageNumber = this.#pagesMapper.getPageNumber(id);
|
||||||
|
if (mappedPageNumber !== pageNumber) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
shouldScroll = percent < 100;
|
shouldScroll = percent < 100;
|
||||||
@ -228,6 +283,7 @@ class PDFThumbnailViewer {
|
|||||||
maxCanvasDim: this.maxCanvasDim,
|
maxCanvasDim: this.maxCanvasDim,
|
||||||
pageColors: this.pageColors,
|
pageColors: this.pageColors,
|
||||||
enableHWA: this.enableHWA,
|
enableHWA: this.enableHWA,
|
||||||
|
enableSplitMerge: this.#enableSplitMerge,
|
||||||
});
|
});
|
||||||
this._thumbnails.push(thumbnail);
|
this._thumbnails.push(thumbnail);
|
||||||
}
|
}
|
||||||
@ -323,7 +379,295 @@ class PDFThumbnailViewer {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #getScaleFactor(image) {
|
||||||
|
return (PDFThumbnailViewer.#draggingScaleFactor ||= parseFloat(
|
||||||
|
getComputedStyle(image).getPropertyValue("--thumbnail-dragging-scale")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#onStartDragging(draggedThumbnail) {
|
||||||
|
this.#currentScrollTop = this.scrollableContainer.scrollTop;
|
||||||
|
this.#currentScrollBottom =
|
||||||
|
this.#currentScrollTop + this.scrollableContainer.clientHeight;
|
||||||
|
this.#dragAC = new AbortController();
|
||||||
|
this.container.classList.add("isDragging");
|
||||||
|
const startPageNumber = parseInt(
|
||||||
|
draggedThumbnail.getAttribute("page-number"),
|
||||||
|
10
|
||||||
|
);
|
||||||
|
this.#lastDraggedOverIndex = startPageNumber - 1;
|
||||||
|
if (!this.#selectedPages?.has(startPageNumber)) {
|
||||||
|
this.#pageNumberToRemove = startPageNumber;
|
||||||
|
this.#selectPage(startPageNumber, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const selected of this.#selectedPages) {
|
||||||
|
const thumbnail = this._thumbnails[selected - 1];
|
||||||
|
const placeholder = (thumbnail.placeholder =
|
||||||
|
document.createElement("div"));
|
||||||
|
placeholder.classList.add("thumbnailImage", "placeholder");
|
||||||
|
const { div, image } = thumbnail;
|
||||||
|
div.classList.add("isDragging");
|
||||||
|
placeholder.style.height = getComputedStyle(image).height;
|
||||||
|
image.after(placeholder);
|
||||||
|
if (selected !== startPageNumber) {
|
||||||
|
image.classList.add("hidden");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (this.#selectedPages.size === 1) {
|
||||||
|
image.classList.add("draggingThumbnail");
|
||||||
|
this.#draggedContainer = image;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// For multiple selected thumbnails, only the one being dragged is shown
|
||||||
|
// (with the dragging style), while the others are hidden.
|
||||||
|
const draggedContainer = (this.#draggedContainer =
|
||||||
|
document.createElement("div"));
|
||||||
|
draggedContainer.classList.add(
|
||||||
|
"draggingThumbnail",
|
||||||
|
"thumbnailImage",
|
||||||
|
"multiple"
|
||||||
|
);
|
||||||
|
draggedContainer.style.height = getComputedStyle(image).height;
|
||||||
|
image.replaceWith(draggedContainer);
|
||||||
|
image.classList.remove("thumbnailImage");
|
||||||
|
draggedContainer.append(image);
|
||||||
|
draggedContainer.setAttribute(
|
||||||
|
"data-multiple-count",
|
||||||
|
this.#selectedPages.size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#onStopDragging(isDropping = false) {
|
||||||
|
const draggedContainer = this.#draggedContainer;
|
||||||
|
this.#draggedContainer = null;
|
||||||
|
const lastDraggedOverIndex = this.#lastDraggedOverIndex;
|
||||||
|
this.#lastDraggedOverIndex = NaN;
|
||||||
|
this.#dragMarker?.remove();
|
||||||
|
this.#dragMarker = null;
|
||||||
|
this.#dragAC.abort();
|
||||||
|
this.#dragAC = null;
|
||||||
|
|
||||||
|
this.#originalThumbnails ||= this._thumbnails;
|
||||||
|
|
||||||
|
this.container.classList.remove("isDragging");
|
||||||
|
for (const selected of this.#selectedPages) {
|
||||||
|
const thumbnail = this._thumbnails[selected - 1];
|
||||||
|
const { div, placeholder, image } = thumbnail;
|
||||||
|
placeholder.remove();
|
||||||
|
image.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");
|
||||||
|
} else {
|
||||||
|
draggedContainer.style.translate = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPages = this.#selectedPages;
|
||||||
|
if (
|
||||||
|
!isNaN(lastDraggedOverIndex) &&
|
||||||
|
isDropping &&
|
||||||
|
!(
|
||||||
|
selectedPages.size === 1 &&
|
||||||
|
(selectedPages.has(lastDraggedOverIndex + 1) ||
|
||||||
|
selectedPages.has(lastDraggedOverIndex + 2))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const newIndex = lastDraggedOverIndex + 1;
|
||||||
|
const pagesToMove = Array.from(selectedPages).sort((a, b) => a - b);
|
||||||
|
const movedCount = pagesToMove.length;
|
||||||
|
const thumbnails = this._thumbnails;
|
||||||
|
const pagesMapper = this.#pagesMapper;
|
||||||
|
const N = thumbnails.length;
|
||||||
|
pagesMapper.pagesNumber = N;
|
||||||
|
const currentPageId = pagesMapper.getPageId(this._currentPageNumber);
|
||||||
|
|
||||||
|
// Move the thumbnails in the DOM.
|
||||||
|
let thumbnail = thumbnails[pagesToMove[0] - 1];
|
||||||
|
thumbnail.checkbox.checked = false;
|
||||||
|
if (newIndex === 0) {
|
||||||
|
thumbnails[0].div.before(thumbnail.div);
|
||||||
|
} else {
|
||||||
|
thumbnails[newIndex - 1].div.after(thumbnail.div);
|
||||||
|
}
|
||||||
|
for (let i = 1; i < movedCount; i++) {
|
||||||
|
const newThumbnail = thumbnails[pagesToMove[i] - 1];
|
||||||
|
newThumbnail.checkbox.checked = false;
|
||||||
|
thumbnail.div.after(newThumbnail.div);
|
||||||
|
thumbnail = newThumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventBus.dispatch("beforepagesedited", {
|
||||||
|
source: this,
|
||||||
|
pagesMapper,
|
||||||
|
index: newIndex,
|
||||||
|
pagesToMove,
|
||||||
|
});
|
||||||
|
|
||||||
|
pagesMapper.movePages(selectedPages, pagesToMove, newIndex);
|
||||||
|
|
||||||
|
const newThumbnails = (this._thumbnails = new Array(N));
|
||||||
|
const originalThumbnails = this.#originalThumbnails;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const newThumbnail = (newThumbnails[i] =
|
||||||
|
originalThumbnails[pagesMapper.getPageId(i + 1) - 1]);
|
||||||
|
newThumbnail.div.setAttribute("page-number", i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentPageNumber = pagesMapper.getPageNumber(currentPageId);
|
||||||
|
this.#computeThumbnailsPosition();
|
||||||
|
|
||||||
|
selectedPages.clear();
|
||||||
|
this.#pageNumberToRemove = NaN;
|
||||||
|
|
||||||
|
this.eventBus.dispatch("pagesedited", {
|
||||||
|
source: this,
|
||||||
|
pagesMapper,
|
||||||
|
index: newIndex,
|
||||||
|
pagesToMove,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNaN(this.#pageNumberToRemove)) {
|
||||||
|
this.#selectPage(this.#pageNumberToRemove, false);
|
||||||
|
this.#pageNumberToRemove = NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#moveDraggedContainer(dx, dy) {
|
||||||
|
this.#draggedImageOffsetX += dx;
|
||||||
|
this.#draggedImageOffsetY += dy;
|
||||||
|
this.#draggedImageX += dx;
|
||||||
|
this.#draggedImageY += dy;
|
||||||
|
this.#draggedContainer.style.translate = `${this.#draggedImageOffsetX}px ${this.#draggedImageOffsetY}px`;
|
||||||
|
if (
|
||||||
|
this.#draggedImageY + this.#draggedImageHeight >
|
||||||
|
this.#currentScrollBottom
|
||||||
|
) {
|
||||||
|
this.scrollableContainer.scrollTop = Math.min(
|
||||||
|
this.scrollableContainer.scrollTop + PIXELS_TO_SCROLL_WHEN_DRAGGING,
|
||||||
|
this.scrollableContainer.scrollHeight
|
||||||
|
);
|
||||||
|
} else if (this.#draggedImageY < this.#currentScrollTop) {
|
||||||
|
this.scrollableContainer.scrollTop = Math.max(
|
||||||
|
this.scrollableContainer.scrollTop - PIXELS_TO_SCROLL_WHEN_DRAGGING,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionData = this.#findClosestThumbnail(
|
||||||
|
this.#draggedImageX + this.#draggedImageWidth / 2,
|
||||||
|
this.#draggedImageY + this.#draggedImageHeight / 2
|
||||||
|
);
|
||||||
|
if (!positionData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dragMarker = this.#dragMarker;
|
||||||
|
if (!dragMarker) {
|
||||||
|
dragMarker = this.#dragMarker = document.createElement("div");
|
||||||
|
dragMarker.className = "dragMarker";
|
||||||
|
this.container.firstChild.before(dragMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [index, space] = positionData;
|
||||||
|
const dragMarkerStyle = dragMarker.style;
|
||||||
|
const { bbox, x: xPos } = this.#thumbnailsPositions;
|
||||||
|
let x, y, width, height;
|
||||||
|
if (index < 0) {
|
||||||
|
if (xPos.length === 1) {
|
||||||
|
y = bbox[1] - SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT;
|
||||||
|
x = bbox[4];
|
||||||
|
width = bbox[2];
|
||||||
|
} else {
|
||||||
|
y = bbox[1];
|
||||||
|
x = bbox[0] - SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT;
|
||||||
|
height = bbox[3];
|
||||||
|
}
|
||||||
|
} else if (xPos.length === 1) {
|
||||||
|
y = bbox[index * 4 + 1] + bbox[index * 4 + 3] + space;
|
||||||
|
x = bbox[index * 4];
|
||||||
|
width = bbox[index * 4 + 2];
|
||||||
|
} else {
|
||||||
|
y = bbox[index * 4 + 1];
|
||||||
|
x = bbox[index * 4] + bbox[index * 4 + 2] + space;
|
||||||
|
height = bbox[index * 4 + 3];
|
||||||
|
}
|
||||||
|
dragMarkerStyle.translate = `${x}px ${y}px`;
|
||||||
|
dragMarkerStyle.width = width ? `${width}px` : "";
|
||||||
|
dragMarkerStyle.height = height ? `${height}px` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
#computeThumbnailsPosition() {
|
||||||
|
// Collect the center of each thumbnail.
|
||||||
|
// This is used to determine the closest thumbnail when dragging.
|
||||||
|
// TODO: handle the RTL case.
|
||||||
|
const positionsX = [];
|
||||||
|
const positionsY = [];
|
||||||
|
const positionsLastX = [];
|
||||||
|
const bbox = new Float32Array(this._thumbnails.length * 4);
|
||||||
|
let prevX = -Infinity;
|
||||||
|
let prevY = -Infinity;
|
||||||
|
let reminder = -1;
|
||||||
|
let firstRightX;
|
||||||
|
let lastRightX;
|
||||||
|
let firstBottomY;
|
||||||
|
for (let i = 0, ii = this._thumbnails.length; i < ii; i++) {
|
||||||
|
const { div } = this._thumbnails[i];
|
||||||
|
const {
|
||||||
|
offsetTop: y,
|
||||||
|
offsetLeft: x,
|
||||||
|
offsetWidth: w,
|
||||||
|
offsetHeight: h,
|
||||||
|
} = div;
|
||||||
|
bbox[i * 4] = x;
|
||||||
|
bbox[i * 4 + 1] = y;
|
||||||
|
bbox[i * 4 + 2] = w;
|
||||||
|
bbox[i * 4 + 3] = h;
|
||||||
|
if (x > prevX) {
|
||||||
|
prevX = x + w / 2;
|
||||||
|
firstRightX ??= prevX + w;
|
||||||
|
positionsX.push(prevX);
|
||||||
|
}
|
||||||
|
if (reminder > 0 && i >= ii - reminder) {
|
||||||
|
const cx = x + w / 2;
|
||||||
|
positionsLastX.push(cx);
|
||||||
|
lastRightX ??= cx + w;
|
||||||
|
}
|
||||||
|
if (y > prevY) {
|
||||||
|
if (reminder === -1 && positionsX.length > 1) {
|
||||||
|
reminder = ii % positionsX.length;
|
||||||
|
}
|
||||||
|
prevY = y + h / 2;
|
||||||
|
firstBottomY ??= prevY + h;
|
||||||
|
positionsY.push(prevY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const space =
|
||||||
|
positionsX.length > 1
|
||||||
|
? (positionsX[1] - firstRightX) / 2
|
||||||
|
: (positionsY[1] - firstBottomY) / 2;
|
||||||
|
this.#thumbnailsPositions = {
|
||||||
|
x: positionsX,
|
||||||
|
y: positionsY,
|
||||||
|
lastX: positionsLastX,
|
||||||
|
space,
|
||||||
|
lastSpace: (positionsLastX.at(-1) - lastRightX) / 2,
|
||||||
|
bbox,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#addEventListeners() {
|
#addEventListeners() {
|
||||||
|
this.eventBus.on("resize", ({ source }) => {
|
||||||
|
if (source.thumbnailsView === this.container) {
|
||||||
|
this.#computeThumbnailsPosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
this.container.addEventListener("keydown", e => {
|
this.container.addEventListener("keydown", e => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
@ -356,7 +700,159 @@ class PDFThumbnailViewer {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.container.addEventListener("click", this.#goToPage.bind(this));
|
this.container.addEventListener("click", e => {
|
||||||
|
const { target } = e;
|
||||||
|
if (target instanceof HTMLInputElement) {
|
||||||
|
const pageNumber = parseInt(
|
||||||
|
target.parentElement.getAttribute("page-number"),
|
||||||
|
10
|
||||||
|
);
|
||||||
|
this.#selectPage(pageNumber, target.checked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#goToPage(e);
|
||||||
|
});
|
||||||
|
this.#addDragListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
#selectPage(pageNumber, checked) {
|
||||||
|
const set = (this.#selectedPages ??= new Set());
|
||||||
|
if (checked) {
|
||||||
|
set.add(pageNumber);
|
||||||
|
} else {
|
||||||
|
set.delete(pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#addDragListeners() {
|
||||||
|
if (!this.#enableSplitMerge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.container.addEventListener("pointerdown", e => {
|
||||||
|
const {
|
||||||
|
target: draggedImage,
|
||||||
|
clientX: clickX,
|
||||||
|
clientY: clickY,
|
||||||
|
pointerId: dragPointerId,
|
||||||
|
} = e;
|
||||||
|
if (
|
||||||
|
!isNaN(this.#lastDraggedOverIndex) ||
|
||||||
|
!draggedImage.classList.contains("thumbnailImage")
|
||||||
|
) {
|
||||||
|
// We're already handling a drag, or the target is not draggable.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnail = draggedImage.parentElement;
|
||||||
|
const pointerDownAC = new AbortController();
|
||||||
|
const { signal: pointerDownSignal } = pointerDownAC;
|
||||||
|
let prevDragX = clickX;
|
||||||
|
let prevDragY = clickY;
|
||||||
|
let prevScrollTop = this.scrollableContainer.scrollTop;
|
||||||
|
|
||||||
|
// When dragging, the thumbnail is scaled down. To keep the cursor at the
|
||||||
|
// same position on the thumbnail, we need to adjust the offset
|
||||||
|
// accordingly.
|
||||||
|
const scaleFactor = PDFThumbnailViewer.#getScaleFactor(draggedImage);
|
||||||
|
this.#draggedImageOffsetX =
|
||||||
|
((scaleFactor - 1) * e.layerX + draggedImage.offsetLeft) / scaleFactor;
|
||||||
|
this.#draggedImageOffsetY =
|
||||||
|
((scaleFactor - 1) * e.layerY + draggedImage.offsetTop) / scaleFactor;
|
||||||
|
|
||||||
|
this.#draggedImageX = thumbnail.offsetLeft + this.#draggedImageOffsetX;
|
||||||
|
this.#draggedImageY = thumbnail.offsetTop + this.#draggedImageOffsetY;
|
||||||
|
this.#draggedImageWidth = draggedImage.offsetWidth / scaleFactor;
|
||||||
|
this.#draggedImageHeight = draggedImage.offsetHeight / scaleFactor;
|
||||||
|
|
||||||
|
this.container.addEventListener(
|
||||||
|
"pointermove",
|
||||||
|
ev => {
|
||||||
|
const { clientX: x, clientY: y, pointerId } = ev;
|
||||||
|
if (
|
||||||
|
pointerId !== dragPointerId ||
|
||||||
|
(Math.abs(x - clickX) <= DRAG_THRESHOLD_IN_PIXELS &&
|
||||||
|
Math.abs(y - clickY) <= DRAG_THRESHOLD_IN_PIXELS)
|
||||||
|
) {
|
||||||
|
// Not enough movement to be considered a drag.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(this.#lastDraggedOverIndex)) {
|
||||||
|
// First movement while dragging.
|
||||||
|
this.#onStartDragging(thumbnail);
|
||||||
|
const stopDragging = (_e, isDropping = false) => {
|
||||||
|
this.#onStopDragging(isDropping);
|
||||||
|
pointerDownAC.abort();
|
||||||
|
};
|
||||||
|
const { signal } = this.#dragAC;
|
||||||
|
window.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
stopEvent /* Prevent the container from scrolling */,
|
||||||
|
{ passive: false, signal }
|
||||||
|
);
|
||||||
|
window.addEventListener("contextmenu", noContextMenu, { signal });
|
||||||
|
this.scrollableContainer.addEventListener(
|
||||||
|
"scrollend",
|
||||||
|
() => {
|
||||||
|
const {
|
||||||
|
scrollableContainer: { clientHeight, scrollTop },
|
||||||
|
} = this;
|
||||||
|
this.#currentScrollTop = scrollTop;
|
||||||
|
this.#currentScrollBottom = scrollTop + clientHeight;
|
||||||
|
const dy = scrollTop - prevScrollTop;
|
||||||
|
prevScrollTop = scrollTop;
|
||||||
|
this.#moveDraggedContainer(0, dy);
|
||||||
|
},
|
||||||
|
{ passive: true, signal }
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
"pointerup",
|
||||||
|
upEv => {
|
||||||
|
if (upEv.pointerId !== dragPointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prevent the subsequent click event after pointerup.
|
||||||
|
window.addEventListener("click", stopEvent, {
|
||||||
|
capture: true,
|
||||||
|
once: true,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
stopEvent(upEv);
|
||||||
|
stopDragging(upEv, /* isDropping = */ true);
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
window.addEventListener("blur", stopDragging, { signal });
|
||||||
|
window.addEventListener("pointercancel", stopDragging, { signal });
|
||||||
|
window.addEventListener("wheel", stopEvent, {
|
||||||
|
passive: false,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = x - prevDragX;
|
||||||
|
const dy = y - prevDragY;
|
||||||
|
prevDragX = x;
|
||||||
|
prevDragY = y;
|
||||||
|
this.#moveDraggedContainer(dx, dy);
|
||||||
|
},
|
||||||
|
{ passive: true, signal: pointerDownSignal }
|
||||||
|
);
|
||||||
|
window.addEventListener(
|
||||||
|
"pointerup",
|
||||||
|
({ pointerId }) => {
|
||||||
|
if (pointerId !== dragPointerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pointerDownAC.abort();
|
||||||
|
},
|
||||||
|
{ signal: pointerDownSignal }
|
||||||
|
);
|
||||||
|
window.addEventListener("dragstart", stopEvent, {
|
||||||
|
capture: true,
|
||||||
|
signal: pointerDownSignal,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#goToPage(e) {
|
#goToPage(e) {
|
||||||
@ -423,6 +919,61 @@ class PDFThumbnailViewer {
|
|||||||
nextThumbnail.image.focus();
|
nextThumbnail.image.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#findClosestThumbnail(x, y) {
|
||||||
|
if (!this.#thumbnailsPositions) {
|
||||||
|
this.#computeThumbnailsPosition();
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
x: positionsX,
|
||||||
|
y: positionsY,
|
||||||
|
lastX: positionsLastX,
|
||||||
|
space: spaceBetweenThumbnails,
|
||||||
|
lastSpace: lastSpaceBetweenThumbnails,
|
||||||
|
} = this.#thumbnailsPositions;
|
||||||
|
const lastDraggedOverIndex = this.#lastDraggedOverIndex;
|
||||||
|
let xPos = lastDraggedOverIndex % positionsX.length;
|
||||||
|
let yPos = Math.floor(lastDraggedOverIndex / positionsX.length);
|
||||||
|
let xArray = yPos === positionsY.length - 1 ? positionsLastX : positionsX;
|
||||||
|
if (
|
||||||
|
positionsY[yPos] <= y &&
|
||||||
|
y < (positionsY[yPos + 1] ?? Infinity) &&
|
||||||
|
xArray[xPos] <= x &&
|
||||||
|
x < (xArray[xPos + 1] ?? Infinity)
|
||||||
|
) {
|
||||||
|
// Fast-path: we're still in the same thumbnail.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPos = binarySearchFirstItem(positionsY, cy => y < cy) - 1;
|
||||||
|
xArray =
|
||||||
|
yPos === positionsY.length - 1 && positionsLastX.length > 0
|
||||||
|
? positionsLastX
|
||||||
|
: positionsX;
|
||||||
|
xPos = Math.max(0, binarySearchFirstItem(xArray, cx => x < cx) - 1);
|
||||||
|
if (yPos < 0) {
|
||||||
|
if (xPos <= 0) {
|
||||||
|
xPos = -1;
|
||||||
|
}
|
||||||
|
yPos = 0;
|
||||||
|
}
|
||||||
|
const index = MathClamp(
|
||||||
|
yPos * positionsX.length + xPos,
|
||||||
|
-1,
|
||||||
|
this._thumbnails.length - 1
|
||||||
|
);
|
||||||
|
if (index === lastDraggedOverIndex) {
|
||||||
|
// No change.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.#lastDraggedOverIndex = index;
|
||||||
|
const space =
|
||||||
|
yPos === positionsY.length - 1 && positionsLastX.length > 0 && xPos >= 0
|
||||||
|
? lastSpaceBetweenThumbnails
|
||||||
|
: spaceBetweenThumbnails;
|
||||||
|
|
||||||
|
return [index, space];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { PDFThumbnailViewer };
|
export { PDFThumbnailViewer };
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import {
|
|||||||
MAX_AUTO_SCALE,
|
MAX_AUTO_SCALE,
|
||||||
MAX_SCALE,
|
MAX_SCALE,
|
||||||
MIN_SCALE,
|
MIN_SCALE,
|
||||||
|
PagesMapper,
|
||||||
PresentationModeState,
|
PresentationModeState,
|
||||||
removeNullCharacters,
|
removeNullCharacters,
|
||||||
RenderingStates,
|
RenderingStates,
|
||||||
@ -288,6 +289,10 @@ class PDFViewer {
|
|||||||
|
|
||||||
#viewerAlert = null;
|
#viewerAlert = null;
|
||||||
|
|
||||||
|
#originalPages = null;
|
||||||
|
|
||||||
|
#pagesMapper = PagesMapper.instance;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {PDFViewerOptions} options
|
* @param {PDFViewerOptions} options
|
||||||
*/
|
*/
|
||||||
@ -1171,6 +1176,39 @@ class PDFViewer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforePagesEdited() {
|
||||||
|
this._currentPageId = this.#pagesMapper.getPageId(this._currentPageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPagesEdited({ index, pagesToMove }) {
|
||||||
|
const pagesMapper = this.#pagesMapper;
|
||||||
|
this._currentPageNumber = pagesMapper.getPageNumber(this._currentPageId);
|
||||||
|
|
||||||
|
const viewerElement =
|
||||||
|
this._scrollMode === ScrollMode.PAGE ? null : this.viewer;
|
||||||
|
if (viewerElement) {
|
||||||
|
const pages = this._pages;
|
||||||
|
let page = pages[pagesToMove[0] - 1].div;
|
||||||
|
if (index === 0) {
|
||||||
|
pages[0].div.before(page);
|
||||||
|
} else {
|
||||||
|
pages[index - 1].div.after(page);
|
||||||
|
}
|
||||||
|
for (let i = 1, ii = pagesToMove.length; i < ii; i++) {
|
||||||
|
const newPage = pages[pagesToMove[i] - 1].div;
|
||||||
|
page.after(newPage);
|
||||||
|
page = newPage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#originalPages ||= this._pages;
|
||||||
|
const newPages = (this._pages = []);
|
||||||
|
for (let i = 0, ii = pagesMapper.pagesNumber; i < ii; i++) {
|
||||||
|
const pageView = this.#originalPages[pagesMapper.getPageId(i + 1) - 1];
|
||||||
|
newPages.push(pageView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Array|null} labels
|
* @param {Array|null} labels
|
||||||
*/
|
*/
|
||||||
@ -1315,11 +1353,12 @@ class PDFViewer {
|
|||||||
|
|
||||||
#scrollIntoView(pageView, pageSpot = null) {
|
#scrollIntoView(pageView, pageSpot = null) {
|
||||||
const { div, id } = pageView;
|
const { div, id } = pageView;
|
||||||
|
const pageNumber = this.#pagesMapper.getPageNumber(id);
|
||||||
|
|
||||||
// Ensure that `this._currentPageNumber` is correct, when `#scrollIntoView`
|
// Ensure that `this._currentPageNumber` is correct, when `#scrollIntoView`
|
||||||
// is called directly (and not from `#resetCurrentPageView`).
|
// is called directly (and not from `#resetCurrentPageView`).
|
||||||
if (this._currentPageNumber !== id) {
|
if (this._currentPageNumber !== pageNumber) {
|
||||||
this._setCurrentPageNumber(id);
|
this._setCurrentPageNumber(pageNumber);
|
||||||
}
|
}
|
||||||
if (this._scrollMode === ScrollMode.PAGE) {
|
if (this._scrollMode === ScrollMode.PAGE) {
|
||||||
this.#ensurePageViewVisible();
|
this.#ensurePageViewVisible();
|
||||||
@ -1780,7 +1819,7 @@ class PDFViewer {
|
|||||||
this._spreadMode === SpreadMode.NONE &&
|
this._spreadMode === SpreadMode.NONE &&
|
||||||
(this._scrollMode === ScrollMode.PAGE ||
|
(this._scrollMode === ScrollMode.PAGE ||
|
||||||
this._scrollMode === ScrollMode.VERTICAL);
|
this._scrollMode === ScrollMode.VERTICAL);
|
||||||
const currentId = this._currentPageNumber;
|
const currentId = this.#pagesMapper.getPageId(this._currentPageNumber);
|
||||||
let stillFullyVisible = false;
|
let stillFullyVisible = false;
|
||||||
|
|
||||||
for (const page of visiblePages) {
|
for (const page of visiblePages) {
|
||||||
@ -1793,7 +1832,9 @@ class PDFViewer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._setCurrentPageNumber(
|
this._setCurrentPageNumber(
|
||||||
stillFullyVisible ? currentId : visiblePages[0].id
|
stillFullyVisible
|
||||||
|
? this._currentPageNumber
|
||||||
|
: this.#pagesMapper.getPageNumber(visiblePages[0].id)
|
||||||
);
|
);
|
||||||
|
|
||||||
this._updateLocation(visible.first);
|
this._updateLocation(visible.first);
|
||||||
|
|||||||
139
web/ui_utils.js
139
web/ui_utils.js
@ -13,7 +13,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MathClamp } from "pdfjs-lib";
|
import { MathClamp, shadow } from "pdfjs-lib";
|
||||||
|
|
||||||
const DEFAULT_SCALE_VALUE = "auto";
|
const DEFAULT_SCALE_VALUE = "auto";
|
||||||
const DEFAULT_SCALE = 1.0;
|
const DEFAULT_SCALE = 1.0;
|
||||||
@ -883,6 +883,142 @@ const calcRound =
|
|||||||
return e.style.width === "calc(1320px)" ? Math.fround : x => x;
|
return e.style.width === "calc(1320px)" ? Math.fround : x => x;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps between page IDs and page numbers, allowing bidirectional conversion
|
||||||
|
* between the two representations. This is useful when the page numbering
|
||||||
|
* in the PDF document doesn't match the default sequential ordering.
|
||||||
|
*/
|
||||||
|
class PagesMapper {
|
||||||
|
/**
|
||||||
|
* Maps page IDs to their corresponding page numbers.
|
||||||
|
* @type {Uint32Array|null}
|
||||||
|
*/
|
||||||
|
static #idToPageNumber = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps page numbers to their corresponding page IDs.
|
||||||
|
* @type {Uint32Array|null}
|
||||||
|
*/
|
||||||
|
static #pageNumberToId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total number of pages.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
static #pagesNumber = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total number of pages.
|
||||||
|
* @returns {number} The number of pages.
|
||||||
|
*/
|
||||||
|
get pagesNumber() {
|
||||||
|
return PagesMapper.#pagesNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the total number of pages and initializes default mappings
|
||||||
|
* where page IDs equal page numbers (1-indexed).
|
||||||
|
* @param {number} n - The total number of pages.
|
||||||
|
*/
|
||||||
|
set pagesNumber(n) {
|
||||||
|
if (PagesMapper.#pagesNumber === n) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PagesMapper.#pagesNumber = n;
|
||||||
|
const pageNumberToId = (PagesMapper.#pageNumberToId = new Uint32Array(
|
||||||
|
2 * n
|
||||||
|
));
|
||||||
|
const idToPageNumber = (PagesMapper.#idToPageNumber =
|
||||||
|
pageNumberToId.subarray(n));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
pageNumberToId[i] = idToPageNumber[i] = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a set of pages to a new position while keeping ID→number mappings in
|
||||||
|
* sync.
|
||||||
|
*
|
||||||
|
* @param {Set<number>} selectedPages - Page numbers being moved (1-indexed).
|
||||||
|
* @param {number[]} pagesToMove - Ordered list of page numbers to move.
|
||||||
|
* @param {number} index - Zero-based insertion index in the page-number list.
|
||||||
|
*/
|
||||||
|
movePages(selectedPages, pagesToMove, index) {
|
||||||
|
const pageNumberToId = PagesMapper.#pageNumberToId;
|
||||||
|
const idToPageNumber = PagesMapper.#idToPageNumber;
|
||||||
|
const movedCount = pagesToMove.length;
|
||||||
|
const mappedPagesToMove = new Uint32Array(movedCount);
|
||||||
|
let removedBeforeTarget = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < movedCount; i++) {
|
||||||
|
const pageIndex = pagesToMove[i] - 1;
|
||||||
|
mappedPagesToMove[i] = pageNumberToId[pageIndex];
|
||||||
|
if (pageIndex < index) {
|
||||||
|
removedBeforeTarget += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagesNumber = PagesMapper.#pagesNumber;
|
||||||
|
// target index after removing elements that were before it
|
||||||
|
let adjustedTarget = index - removedBeforeTarget;
|
||||||
|
const remainingLen = pagesNumber - movedCount;
|
||||||
|
adjustedTarget = MathClamp(adjustedTarget, 0, remainingLen);
|
||||||
|
|
||||||
|
// Create the new mapping.
|
||||||
|
// First copy over the pages that are not being moved.
|
||||||
|
// Then insert the moved pages at the target position.
|
||||||
|
for (let i = 0, r = 0; i < pagesNumber; i++) {
|
||||||
|
if (!selectedPages.has(i + 1)) {
|
||||||
|
pageNumberToId[r++] = pageNumberToId[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift the pages after the target position.
|
||||||
|
pageNumberToId.copyWithin(
|
||||||
|
adjustedTarget + movedCount,
|
||||||
|
adjustedTarget,
|
||||||
|
remainingLen
|
||||||
|
);
|
||||||
|
// Finally insert the moved pages.
|
||||||
|
pageNumberToId.set(mappedPagesToMove, adjustedTarget);
|
||||||
|
|
||||||
|
for (let i = 0, ii = pagesNumber; i < ii; i++) {
|
||||||
|
idToPageNumber[pageNumberToId[i] - 1] = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
getPageNumber(id) {
|
||||||
|
return PagesMapper.#idToPageNumber?.[id - 1] ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the page ID for a given page number.
|
||||||
|
* @param {number} pageNumber - The page number (1-indexed).
|
||||||
|
* @returns {number} The page ID, or the page number itself if no mapping
|
||||||
|
* exists.
|
||||||
|
*/
|
||||||
|
getPageId(pageNumber) {
|
||||||
|
return PagesMapper.#pageNumberToId?.[pageNumber - 1] ?? pageNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or creates a singleton instance of PagesMapper.
|
||||||
|
* @returns {PagesMapper} The singleton instance.
|
||||||
|
*/
|
||||||
|
static get instance() {
|
||||||
|
return shadow(this, "instance", new PagesMapper());
|
||||||
|
}
|
||||||
|
|
||||||
|
getMapping() {
|
||||||
|
return PagesMapper.#pageNumberToId.subarray(0, this.pagesNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
animationStarted,
|
animationStarted,
|
||||||
apiPageLayoutToViewerModes,
|
apiPageLayoutToViewerModes,
|
||||||
@ -910,6 +1046,7 @@ export {
|
|||||||
MIN_SCALE,
|
MIN_SCALE,
|
||||||
normalizeWheelEventDelta,
|
normalizeWheelEventDelta,
|
||||||
normalizeWheelEventDirection,
|
normalizeWheelEventDirection,
|
||||||
|
PagesMapper,
|
||||||
parseQueryString,
|
parseQueryString,
|
||||||
PresentationModeState,
|
PresentationModeState,
|
||||||
ProgressBar,
|
ProgressBar,
|
||||||
|
|||||||
@ -87,25 +87,42 @@
|
|||||||
0 0.25px 0.75px -0.75px light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
0 0.25px 0.75px -0.75px light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
||||||
0 2px 6px -6px light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
0 2px 6px -6px light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
||||||
--image-outline: none;
|
--image-outline: none;
|
||||||
--image-border-width: 4px;
|
--image-border-width: 6px;
|
||||||
--image-border-color: light-dark(#cfcfd8, #3a3944);
|
--image-border-color: light-dark(#cfcfd8, #3a3944);
|
||||||
--image-hover-border-color: light-dark(#cfcfd8, #3a3944);
|
--image-hover-border-color: #bfbfc9;
|
||||||
--image-current-border-color: var(--button-focus-outline-color);
|
--image-current-border-color: var(--button-focus-outline-color);
|
||||||
--image-current-focused-outline-color: var(--image-hover-border-color);
|
--image-current-focused-outline-color: var(--image-hover-border-color);
|
||||||
--image-page-number-bg: light-dark(#f0f0f4, #23222b);
|
--image-page-number-bg: light-dark(#f0f0f4, #23222b);
|
||||||
--image-page-number-fg: var(--text-color);
|
--image-page-number-fg: var(--text-color);
|
||||||
|
--image-current-page-number-bg: var(--image-current-border-color);
|
||||||
|
--image-current-page-number-fg: light-dark(#fff, #15141a);
|
||||||
--image-shadow:
|
--image-shadow:
|
||||||
0 0.375px 1.5px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
0 0.375px 1.5px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
||||||
0 0 0 1px var(--image-border-color),
|
0 0 0 1px var(--image-border-color),
|
||||||
0 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
0 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
||||||
--image-hover-shadow:
|
--image-hover-shadow:
|
||||||
0 0.375px 1.5px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
0 0.375px 1.5px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
||||||
|
0 0 0 1px light-dark(rgb(21 20 26 / 0.1), rgb(251 251 254 / 0.1)),
|
||||||
0 0 0 var(--image-border-width) var(--image-hover-border-color),
|
0 0 0 var(--image-border-width) var(--image-hover-border-color),
|
||||||
0 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
0 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
||||||
--image-current-shadow:
|
--image-current-shadow:
|
||||||
0 0.375px 1.5px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
0 0.375px 1.5px 0 light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.2)),
|
||||||
0 0 0 var(--image-border-width) var(--image-current-border-color),
|
0 0 0 var(--image-border-width) var(--image-current-border-color),
|
||||||
0 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
0 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4));
|
||||||
|
--image-dragging-placeholder-bg: light-dark(
|
||||||
|
rgb(0 98 250 / 0.08),
|
||||||
|
rgb(0 202 219 / 0.08)
|
||||||
|
);
|
||||||
|
--multiple-dragging-bg: white;
|
||||||
|
--image-multiple-dragging-shadow:
|
||||||
|
0 0 0 var(--image-border-width) var(--image-current-border-color),
|
||||||
|
var(--image-border-width) var(--image-border-width) 0
|
||||||
|
calc(var(--image-border-width) / 2) var(--multiple-dragging-bg),
|
||||||
|
var(--image-border-width) var(--image-border-width) 0
|
||||||
|
calc(3 * var(--image-border-width) / 2) var(--image-current-border-color);
|
||||||
|
--image-dragging-shadow: 0 0 0 var(--image-border-width)
|
||||||
|
var(--image-current-border-color);
|
||||||
|
--multiple-dragging-text-color: light-dark(#fbfbfe, #15141a);
|
||||||
|
|
||||||
@media screen and (forced-colors: active) {
|
@media screen and (forced-colors: active) {
|
||||||
--text-color: CanvasText;
|
--text-color: CanvasText;
|
||||||
@ -136,6 +153,7 @@
|
|||||||
--image-current-focused-outline-color: var(--image-hover-border-color);
|
--image-current-focused-outline-color: var(--image-hover-border-color);
|
||||||
--image-page-number-bg: ButtonFace;
|
--image-page-number-bg: ButtonFace;
|
||||||
--image-page-number-fg: CanvasText;
|
--image-page-number-fg: CanvasText;
|
||||||
|
--multiple-dragging-bg: Canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -494,6 +512,10 @@
|
|||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
|
&:has(#thumbnailsView.isDragging) {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
#thumbnailsView {
|
#thumbnailsView {
|
||||||
--thumbnail-width: 126px;
|
--thumbnail-width: 126px;
|
||||||
|
|
||||||
@ -502,9 +524,36 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
padding: 20px 32px;
|
padding: 20px 32px;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.isDragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
|
||||||
|
> .thumbnail {
|
||||||
|
> .thumbnailImage:hover {
|
||||||
|
cursor: grabbing;
|
||||||
|
|
||||||
|
&:not([aria-current="page"]) {
|
||||||
|
box-shadow: var(--image-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> input {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .dragMarker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border: 2px solid var(--indicator-color);
|
||||||
|
contain: strict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .thumbnail {
|
> .thumbnail {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -516,38 +565,52 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
scroll-margin-top: 20px;
|
scroll-margin-top: 20px;
|
||||||
|
|
||||||
> input {
|
&:not(.isDragging)::after {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: attr(page-number);
|
content: attr(page-number);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: var(--image-page-number-bg);
|
background-color: var(--image-page-number-bg);
|
||||||
color: var(--image-page-number-fg);
|
color: var(--image-page-number-fg);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 5px;
|
bottom: 5px;
|
||||||
right: calc(var(--thumbnail-width) / 2);
|
inset-inline-end: calc(var(--thumbnail-width) / 2);
|
||||||
min-width: 32px;
|
min-width: 32px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
translate: 50%;
|
translate: calc(var(--dir-factor) * 50%);
|
||||||
|
|
||||||
font: menu;
|
font: menu;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: normal;
|
line-height: normal;
|
||||||
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has([aria-current="page"]):not(.isDragging)::after {
|
||||||
|
background-color: var(--image-current-page-number-bg);
|
||||||
|
color: var(--image-current-page-number-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.isDragging > input {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
> input {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .thumbnailImage {
|
> .thumbnailImage {
|
||||||
|
--thumbnail-dragging-scale: 1.4;
|
||||||
|
|
||||||
width: var(--thumbnail-width);
|
width: var(--thumbnail-width);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: var(--image-shadow);
|
box-shadow: var(--image-shadow);
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
outline: var(--image-outline);
|
outline: var(--image-outline);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
&.missingThumbnailImage {
|
&.missingThumbnailImage {
|
||||||
content-visibility: hidden;
|
content-visibility: hidden;
|
||||||
@ -574,6 +637,58 @@
|
|||||||
&[aria-current="page"] {
|
&[aria-current="page"] {
|
||||||
box-shadow: var(--image-current-shadow);
|
box-shadow: var(--image-current-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.placeholder {
|
||||||
|
background-color: var(--image-dragging-placeholder-bg);
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.draggingThumbnail {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
transform-origin: 0 0 0;
|
||||||
|
scale: calc(1 / var(--thumbnail-dragging-scale));
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--image-dragging-shadow);
|
||||||
|
|
||||||
|
&.multiple {
|
||||||
|
box-shadow: var(--image-multiple-dragging-shadow);
|
||||||
|
|
||||||
|
> img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
width: var(--thumbnail-width);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: attr(data-multiple-count);
|
||||||
|
border-radius: calc(8px * var(--thumbnail-dragging-scale));
|
||||||
|
background-color: var(--indicator-color);
|
||||||
|
color: var(--multiple-dragging-text-color);
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: calc(4px * var(--thumbnail-dragging-scale));
|
||||||
|
inset-inline-start: calc(4px * var(--thumbnail-dragging-scale));
|
||||||
|
min-width: calc(32px * var(--thumbnail-dragging-scale));
|
||||||
|
height: calc(16px * var(--thumbnail-dragging-scale));
|
||||||
|
text-align: center;
|
||||||
|
font: menu;
|
||||||
|
font-size: calc(13px * var(--thumbnail-dragging-scale));
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
contain: strict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user