From b7933d8750835d5743c4aa724b408701ded6588f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Tue, 21 May 2024 16:38:46 +0200 Subject: [PATCH] Add `origin` parameter to `updateScale` This parameter allows defining which point should remain fixed while scaling the document. It can be used, for example, to implement "zoom around the cursor" or "zoom around pinch center". The logic was previously implemented in `web/app.js`, but moving it to the viewer scaling utilities themselves makes it easier to implement similar zooming functionalities in other embedders. --- test/integration/viewer_spec.mjs | 80 ++++++++++++++++++++++++++++++++ web/app.js | 40 ++++------------ web/pdf_viewer.js | 18 +++++-- 3 files changed, 105 insertions(+), 33 deletions(-) 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 }); } /**