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 0000000000000..bea6e535068ae Binary files /dev/null and b/test/pdfs/page_with_number.pdf differ 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; + } + } + } } } }