From 5e89981282f0b0a76ad194b1e006698aea354f45 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 6 Jan 2026 21:08:45 +0100 Subject: [PATCH] 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. --- extensions/chromium/preferences_schema.json | 4 + test/integration/jasmine-boot.js | 1 + test/integration/reorganize_pages_spec.mjs | 235 ++++++++ test/integration/test_utils.mjs | 24 + test/pdfs/.gitignore | 1 + test/pdfs/page_with_number.pdf | Bin 0 -> 35537 bytes web/app.js | 16 + web/app_options.js | 5 + web/pdf_thumbnail_view.js | 23 +- web/pdf_thumbnail_viewer.js | 561 +++++++++++++++++++- web/pdf_viewer.js | 49 +- web/ui_utils.js | 139 ++++- web/views_manager.css | 135 ++++- 13 files changed, 1163 insertions(+), 30 deletions(-) create mode 100644 test/integration/reorganize_pages_spec.mjs create mode 100755 test/pdfs/page_with_number.pdf diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 91e6666a524dc..269d71e66f9f0 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -79,6 +79,10 @@ "type": "boolean", "default": false }, + "enableSplitMerge": { + "type": "boolean", + "default": false + }, "enableUpdatedAddImage": { "type": "boolean", "default": false diff --git a/test/integration/jasmine-boot.js b/test/integration/jasmine-boot.js index 51590003cd1ac..44cb39bdb862c 100644 --- a/test/integration/jasmine-boot.js +++ b/test/integration/jasmine-boot.js @@ -37,6 +37,7 @@ async function runTests(results) { "freetext_editor_spec.mjs", "highlight_editor_spec.mjs", "ink_editor_spec.mjs", + "reorganize_pages_spec.mjs", "scripting_spec.mjs", "signature_editor_spec.mjs", "stamp_editor_spec.mjs", diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs new file mode 100644 index 0000000000000..7c0aee17bd6b3 --- /dev/null +++ b/test/integration/reorganize_pages_spec.mjs @@ -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, + ]); + }) + ); + }); + }); +}); diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index 630eb62750f5e..f7d0785daf07f 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -158,6 +158,24 @@ async function waitForSandboxTrip(page) { 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) { /** * Wait for the given number of milliseconds. @@ -234,6 +252,10 @@ function getAnnotationSelector(id) { return `[data-annotation-id="${id}"]`; } +function getThumbnailSelector(pageNumber) { + return `.thumbnailImage[data-l10n-args='{"page":${pageNumber}}']`; +} + async function getSpanRectFromText(page, pageNumber, text) { await page.waitForSelector( `.page[data-page-number="${pageNumber}"] > .textLayer .endOfContent` @@ -957,6 +979,7 @@ export { getSelector, getSerialized, getSpanRectFromText, + getThumbnailSelector, getXY, highlightSpan, isCanvasMonochrome, @@ -991,6 +1014,7 @@ export { waitAndClick, waitForAnnotationEditorLayer, waitForAnnotationModeChanged, + waitForDOMMutation, waitForEntryInStorage, waitForEvent, waitForNoElement, diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 7c5d8ab4bd448..945baa76d33c4 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -868,3 +868,4 @@ !bitmap.pdf !bomb_giant.pdf !bug2009627.pdf +!page_with_number.pdf diff --git a/test/pdfs/page_with_number.pdf b/test/pdfs/page_with_number.pdf new file mode 100755 index 0000000000000000000000000000000000000000..bea6e535068ae850f8a7a95775ab414e52b3bba8 GIT binary patch literal 35537 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 -{- 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 literal 0 HcmV?d00001 diff --git a/web/app.js b/web/app.js index cc1c3e1eaeecd..c25a7afb14234 100644 --- a/web/app.js +++ b/web/app.js @@ -377,6 +377,7 @@ const PDFViewerApplication = { enableFakeMLManager: x => x === "true", enableGuessAltText: x => x === "true", enablePermissions: x => x === "true", + enableSplitMerge: x => x === "true", enableUpdatedAddImage: x => x === "true", highlightEditorColors: x => x, maxCanvasPixels: x => parseInt(x), @@ -602,6 +603,7 @@ const PDFViewerApplication = { pageColors, abortSignal, enableHWA, + enableSplitMerge: AppOptions.get("enableSplitMerge"), }); renderingQueue.setThumbnailViewer(this.pdfThumbnailViewer); } @@ -2185,6 +2187,12 @@ const PDFViewerApplication = { opts ); } + eventBus._on("pagesedited", this.onPagesEdited.bind(this), opts); + eventBus._on( + "beforepagesedited", + this.onBeforePagesEdited.bind(this), + opts + ); }, bindWindowEvents() { @@ -2359,6 +2367,14 @@ const PDFViewerApplication = { await Promise.all([this.l10n?.destroy(), this.close()]); }, + onBeforePagesEdited(data) { + this.pdfViewer.onBeforePagesEdited(data); + }, + + onPagesEdited(data) { + this.pdfViewer.onPagesEdited(data); + }, + _accumulateTicks(ticks, prop) { // If the direction changed, reset the accumulated ticks. if ((this[prop] > 0 && ticks < 0) || (this[prop] < 0 && ticks > 0)) { diff --git a/web/app_options.js b/web/app_options.js index 0238e78a6a27f..4e9901086f112 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -279,6 +279,11 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableSplitMerge: { + /** @type {boolean} */ + value: typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING"), + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableUpdatedAddImage: { // We'll probably want to make some experiments before enabling this // in Firefox release, but it has to be temporary. diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index 9e4a7df66c8e1..1b6caeba99fcf 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -97,6 +97,7 @@ class PDFThumbnailView { maxCanvasPixels, maxCanvasDim, pageColors, + enableSplitMerge = false, }) { this.id = id; this.renderingId = "thumbnail" + id; @@ -118,22 +119,28 @@ class PDFThumbnailView { this.renderTask = null; this.renderingState = RenderingStates.INITIAL; this.resume = null; + this.placeholder = null; const imageContainer = (this.div = document.createElement("div")); imageContainer.className = "thumbnail"; - imageContainer.setAttribute("page-number", this.#pageNumber); - - const checkbox = (this.checkbox = document.createElement("input")); - checkbox.type = "checkbox"; - checkbox.tabIndex = -1; + imageContainer.setAttribute("page-number", id); + imageContainer.setAttribute("page-id", id); + + if (enableSplitMerge) { + const checkbox = (this.checkbox = document.createElement("input")); + checkbox.type = "checkbox"; + checkbox.tabIndex = -1; + imageContainer.append(checkbox); + } const image = (this.image = document.createElement("img")); image.classList.add("thumbnailImage", "missingThumbnailImage"); image.role = "button"; image.tabIndex = -1; + image.draggable = false; this.#updateDims(); - imageContainer.append(checkbox, image); + imageContainer.append(image); container.append(imageContainer); } @@ -440,10 +447,6 @@ class PDFThumbnailView { return JSON.stringify({ page: this.pageLabel ?? this.id }); } - get #pageNumber() { - return this.pageLabel ?? this.id; - } - /** * @param {string|null} label */ diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 5e2607e91751b..0c05d5178bd79 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -21,12 +21,14 @@ /** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */ import { + binarySearchFirstItem, getVisibleElements, isValidRotation, + PagesMapper, RenderingStates, watchScroll, } from "./ui_utils.js"; -import { MathClamp, stopEvent } from "pdfjs-lib"; +import { MathClamp, noContextMenu, stopEvent } from "pdfjs-lib"; import { PDFThumbnailView } from "./pdf_thumbnail_view.js"; const SCROLL_OPTIONS = { @@ -36,6 +38,14 @@ const SCROLL_OPTIONS = { 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 * @property {HTMLDivElement} container - The container for the thumbnail @@ -56,12 +66,52 @@ const SCROLL_OPTIONS = { * events. * @property {boolean} [enableHWA] - Enables hardware acceleration for * 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. */ 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 */ @@ -75,6 +125,7 @@ class PDFThumbnailViewer { pageColors, abortSignal, enableHWA, + enableSplitMerge, }) { this.scrollableContainer = container.parentElement; this.container = container; @@ -85,6 +136,7 @@ class PDFThumbnailViewer { this.maxCanvasDim = maxCanvasDim; this.pageColors = pageColors || null; this.enableHWA = enableHWA || false; + this.#enableSplitMerge = enableSplitMerge || false; this.scroll = watchScroll( this.scrollableContainer, @@ -120,7 +172,6 @@ class PDFThumbnailViewer { console.error('scrollThumbnailIntoView: Invalid "pageNumber" parameter.'); return; } - if (pageNumber !== this._currentPageNumber) { const prevThumbnailView = this._thumbnails[this._currentPageNumber - 1]; prevThumbnailView.toggleCurrent(/* isCurrent = */ false); @@ -132,11 +183,15 @@ class PDFThumbnailViewer { // If the thumbnail isn't currently visible, scroll it into view. if (views.length > 0) { 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; } else { for (const { id, percent } of views) { - if (id !== pageNumber) { + const mappedPageNumber = this.#pagesMapper.getPageNumber(id); + if (mappedPageNumber !== pageNumber) { continue; } shouldScroll = percent < 100; @@ -228,6 +283,7 @@ class PDFThumbnailViewer { maxCanvasDim: this.maxCanvasDim, pageColors: this.pageColors, enableHWA: this.enableHWA, + enableSplitMerge: this.#enableSplitMerge, }); this._thumbnails.push(thumbnail); } @@ -323,7 +379,295 @@ class PDFThumbnailViewer { 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() { + this.eventBus.on("resize", ({ source }) => { + if (source.thumbnailsView === this.container) { + this.#computeThumbnailsPosition(); + } + }); this.container.addEventListener("keydown", e => { switch (e.key) { case "ArrowLeft": @@ -356,7 +700,159 @@ class PDFThumbnailViewer { 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) { @@ -423,6 +919,61 @@ class PDFThumbnailViewer { 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 }; diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 2a96a552ff496..44dfba71afcb0 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -52,6 +52,7 @@ import { MAX_AUTO_SCALE, MAX_SCALE, MIN_SCALE, + PagesMapper, PresentationModeState, removeNullCharacters, RenderingStates, @@ -288,6 +289,10 @@ class PDFViewer { #viewerAlert = null; + #originalPages = null; + + #pagesMapper = PagesMapper.instance; + /** * @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 */ @@ -1315,11 +1353,12 @@ class PDFViewer { #scrollIntoView(pageView, pageSpot = null) { const { div, id } = pageView; + const pageNumber = this.#pagesMapper.getPageNumber(id); // Ensure that `this._currentPageNumber` is correct, when `#scrollIntoView` // is called directly (and not from `#resetCurrentPageView`). - if (this._currentPageNumber !== id) { - this._setCurrentPageNumber(id); + if (this._currentPageNumber !== pageNumber) { + this._setCurrentPageNumber(pageNumber); } if (this._scrollMode === ScrollMode.PAGE) { this.#ensurePageViewVisible(); @@ -1780,7 +1819,7 @@ class PDFViewer { this._spreadMode === SpreadMode.NONE && (this._scrollMode === ScrollMode.PAGE || this._scrollMode === ScrollMode.VERTICAL); - const currentId = this._currentPageNumber; + const currentId = this.#pagesMapper.getPageId(this._currentPageNumber); let stillFullyVisible = false; for (const page of visiblePages) { @@ -1793,7 +1832,9 @@ class PDFViewer { } } this._setCurrentPageNumber( - stillFullyVisible ? currentId : visiblePages[0].id + stillFullyVisible + ? this._currentPageNumber + : this.#pagesMapper.getPageNumber(visiblePages[0].id) ); this._updateLocation(visible.first); diff --git a/web/ui_utils.js b/web/ui_utils.js index 0251c37b69066..c296ff8e5d8fa 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -13,7 +13,7 @@ * limitations under the License. */ -import { MathClamp } from "pdfjs-lib"; +import { MathClamp, shadow } from "pdfjs-lib"; const DEFAULT_SCALE_VALUE = "auto"; const DEFAULT_SCALE = 1.0; @@ -883,6 +883,142 @@ const calcRound = 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} 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 { animationStarted, apiPageLayoutToViewerModes, @@ -910,6 +1046,7 @@ export { MIN_SCALE, normalizeWheelEventDelta, normalizeWheelEventDirection, + PagesMapper, parseQueryString, PresentationModeState, ProgressBar, diff --git a/web/views_manager.css b/web/views_manager.css index b649e483447a9..a77c7947674a9 100644 --- a/web/views_manager.css +++ b/web/views_manager.css @@ -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 2px 6px -6px light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); --image-outline: none; - --image-border-width: 4px; + --image-border-width: 6px; --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-focused-outline-color: var(--image-hover-border-color); --image-page-number-bg: light-dark(#f0f0f4, #23222b); --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: 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 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); --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 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 3px 12px 0 light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.4)); --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 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)); + --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) { --text-color: CanvasText; @@ -136,6 +153,7 @@ --image-current-focused-outline-color: var(--image-hover-border-color); --image-page-number-bg: ButtonFace; --image-page-number-fg: CanvasText; + --multiple-dragging-bg: Canvas; } display: flex; @@ -494,6 +512,10 @@ flex: 1 1 0%; overflow: auto; + &:has(#thumbnailsView.isDragging) { + overflow-x: hidden; + } + #thumbnailsView { --thumbnail-width: 126px; @@ -502,9 +524,36 @@ align-items: center; justify-content: space-evenly; padding: 20px 32px; - gap: 16px; + gap: 20px; width: 100%; 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 { display: inline-flex; @@ -516,38 +565,52 @@ position: relative; scroll-margin-top: 20px; - > input { - display: none; - } - - &::after { + &:not(.isDragging)::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; - right: calc(var(--thumbnail-width) / 2); + inset-inline-end: calc(var(--thumbnail-width) / 2); min-width: 32px; height: 16px; text-align: center; - translate: 50%; + translate: calc(var(--dir-factor) * 50%); font: menu; font-size: 13px; font-style: normal; font-weight: 400; line-height: normal; + 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 { + --thumbnail-dragging-scale: 1.4; + width: var(--thumbnail-width); border: none; border-radius: 8px; box-shadow: var(--image-shadow); box-sizing: content-box; outline: var(--image-outline); + user-select: none; &.missingThumbnailImage { content-visibility: hidden; @@ -574,6 +637,58 @@ &[aria-current="page"] { 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; + } + } + } } } }