diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index d2f2f847fb215..5f780684ab5fe 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -17,10 +17,90 @@ import { awaitPromise, closePages, createPromise, + getSpanRectFromText, loadAndWait, } from "./test_utils.mjs"; describe("PDF viewer", () => { + describe("Zoom origin", () => { + let pages; + + beforeAll(async () => { + pages = await loadAndWait( + "tracemonkey.pdf", + ".textLayer .endOfContent", + "page-width", + null, + { page: 2 } + ); + }); + + afterAll(async () => { + await closePages(pages); + }); + + async function getTextAt(page, pageNumber, coordX, coordY) { + await page.waitForFunction( + pageNum => + !document.querySelector( + `.page[data-page-number="${pageNum}"] > .textLayer` + ).hidden, + {}, + pageNumber + ); + return page.evaluate( + (x, y) => document.elementFromPoint(x, y)?.textContent, + coordX, + coordY + ); + } + + it("supports specifiying a custom origin", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // We use this text span of page 2 because: + // - it's in the visible area even when zooming at page-width + // - it's small, so it easily catches if the page moves too much + // - it's in a "random" position: not near the center of the + // viewport, and not near the borders + const text = "guards"; + + const rect = await getSpanRectFromText(page, 2, text); + const originX = rect.x + rect.width / 2; + const originY = rect.y + rect.height / 2; + + await page.evaluate( + origin => { + window.PDFViewerApplication.pdfViewer.increaseScale({ + scaleFactor: 2, + origin, + }); + }, + [originX, originY] + ); + const textAfterZoomIn = await getTextAt(page, 2, originX, originY); + expect(textAfterZoomIn) + .withContext(`In ${browserName}, zoom in`) + .toBe(text); + + await page.evaluate( + origin => { + window.PDFViewerApplication.pdfViewer.decreaseScale({ + scaleFactor: 0.8, + origin, + }); + }, + [originX, originY] + ); + const textAfterZoomOut = await getTextAt(page, 2, originX, originY); + expect(textAfterZoomOut) + .withContext(`In ${browserName}, zoom out`) + .toBe(text); + }) + ); + }); + }); + describe("Zoom with the mouse wheel", () => { let pages; diff --git a/web/app.js b/web/app.js index 945b331e8e4af..636a721d242b4 100644 --- a/web/app.js +++ b/web/app.js @@ -743,7 +743,7 @@ const PDFViewerApplication = { return this._initializedCapability.promise; }, - updateZoom(steps, scaleFactor) { + updateZoom(steps, scaleFactor, origin) { if (this.pdfViewer.isInPresentationMode) { return; } @@ -751,6 +751,7 @@ const PDFViewerApplication = { drawingDelay: AppOptions.get("defaultZoomDelay"), steps, scaleFactor, + origin, }); }, @@ -2121,16 +2122,6 @@ const PDFViewerApplication = { return newFactor; }, - _centerAtPos(previousScale, x, y) { - const { pdfViewer } = this; - const scaleDiff = pdfViewer.currentScale / previousScale - 1; - if (scaleDiff !== 0) { - const [top, left] = pdfViewer.containerTopLeft; - pdfViewer.container.scrollLeft += (x - left) * scaleDiff; - pdfViewer.container.scrollTop += (y - top) * scaleDiff; - } - }, - /** * Should be called *after* all pages have loaded, or if an error occurred, * to unblock the "load" event; see https://bugzilla.mozilla.org/show_bug.cgi?id=1618553 @@ -2607,6 +2598,7 @@ function webViewerWheel(evt) { evt.deltaX === 0 && (Math.abs(scaleFactor - 1) < 0.05 || isBuiltInMac) && evt.deltaZ === 0; + const origin = [evt.clientX, evt.clientY]; if ( isPinchToZoom || @@ -2625,14 +2617,13 @@ function webViewerWheel(evt) { return; } - const previousScale = pdfViewer.currentScale; if (isPinchToZoom && supportsPinchToZoom) { scaleFactor = PDFViewerApplication._accumulateFactor( - previousScale, + pdfViewer.currentScale, scaleFactor, "_wheelUnusedFactor" ); - PDFViewerApplication.updateZoom(null, scaleFactor); + PDFViewerApplication.updateZoom(null, scaleFactor, origin); } else { const delta = normalizeWheelEventDirection(evt); @@ -2664,13 +2655,8 @@ function webViewerWheel(evt) { ); } - PDFViewerApplication.updateZoom(ticks); + PDFViewerApplication.updateZoom(ticks, null, origin); } - - // After scaling the page via zoomIn/zoomOut, the position of the upper- - // left corner is restored. When the mouse wheel is used, the position - // under the cursor should be restored instead. - PDFViewerApplication._centerAtPos(previousScale, evt.clientX, evt.clientY); } } @@ -2770,30 +2756,24 @@ function webViewerTouchMove(evt) { evt.preventDefault(); + const origin = [(page0X + page1X) / 2, (page0Y + page1Y) / 2]; const distance = Math.hypot(page0X - page1X, page0Y - page1Y) || 1; const pDistance = Math.hypot(pTouch0X - pTouch1X, pTouch0Y - pTouch1Y) || 1; - const previousScale = pdfViewer.currentScale; if (supportsPinchToZoom) { const newScaleFactor = PDFViewerApplication._accumulateFactor( - previousScale, + pdfViewer.currentScale, distance / pDistance, "_touchUnusedFactor" ); - PDFViewerApplication.updateZoom(null, newScaleFactor); + PDFViewerApplication.updateZoom(null, newScaleFactor, origin); } else { const PIXELS_PER_LINE_SCALE = 30; const ticks = PDFViewerApplication._accumulateTicks( (distance - pDistance) / PIXELS_PER_LINE_SCALE, "_touchUnusedTicks" ); - PDFViewerApplication.updateZoom(ticks); + PDFViewerApplication.updateZoom(ticks, null, origin); } - - PDFViewerApplication._centerAtPos( - previousScale, - (page0X + page1X) / 2, - (page0Y + page1Y) / 2 - ); } function webViewerTouchEnd(evt) { diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index d6c3ae8a407ec..d52d1e25a876d 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -1219,7 +1219,7 @@ class PDFViewer { #setScaleUpdatePages( newScale, newValue, - { noScroll = false, preset = false, drawingDelay = -1 } + { noScroll = false, preset = false, drawingDelay = -1, origin = null } ) { this._currentScaleValue = newValue.toString(); @@ -1252,6 +1252,7 @@ class PDFViewer { }, drawingDelay); } + const previousScale = this._currentScale; this._currentScale = newScale; if (!noScroll) { @@ -1275,6 +1276,15 @@ class PDFViewer { destArray: dest, allowNegativeOffset: true, }); + if (Array.isArray(origin)) { + // If the origin of the scaling transform is specified, preserve its + // location on screen. If not specified, scaling will fix the top-left + // corner of the visible PDF area. + const scaleDiff = newScale / previousScale - 1; + const [top, left] = this.containerTopLeft; + this.container.scrollLeft += (origin[0] - left) * scaleDiff; + this.container.scrollTop += (origin[1] - top) * scaleDiff; + } } this.eventBus.dispatch("scalechanging", { @@ -2122,13 +2132,15 @@ class PDFViewer { * @property {number} [drawingDelay] * @property {number} [scaleFactor] * @property {number} [steps] + * @property {Array} [origin] x and y coordinates of the scale + * transformation origin. */ /** * Changes the current zoom level by the specified amount. * @param {ChangeScaleOptions} [options] */ - updateScale({ drawingDelay, scaleFactor = null, steps = null }) { + updateScale({ drawingDelay, scaleFactor = null, steps = null, origin }) { if (steps === null && scaleFactor === null) { throw new Error( "Invalid updateScale options: either `steps` or `scaleFactor` must be provided." @@ -2149,7 +2161,7 @@ class PDFViewer { } while (--steps > 0); } newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); - this.#setScale(newScale, { noScroll: false, drawingDelay }); + this.#setScale(newScale, { noScroll: false, drawingDelay, origin }); } /**