diff --git a/web/app.js b/web/app.js index 9851dc79dd03a..993b4ac1f797a 100644 --- a/web/app.js +++ b/web/app.js @@ -122,6 +122,10 @@ class DefaultExternalServices { throw new Error("Not implemented: createScripting"); } + static get supportsPinchToZoom() { + return shadow(this, "supportsPinchToZoom", true); + } + static get supportsIntegratedFind() { return shadow(this, "supportsIntegratedFind", false); } @@ -213,10 +217,12 @@ const PDFViewerApplication = { _contentLength: null, _saveInProgress: false, _wheelUnusedTicks: 0, + _touchUnusedTicks: 0, _PDFBug: null, _hasAnnotationEditors: false, _title: document.title, _printAnnotationStoragePromise: null, + _touchInfo: null, // Called once when the document is loaded. async initialize(appConfig) { @@ -655,8 +661,9 @@ const PDFViewerApplication = { if (this.pdfViewer.isInPresentationMode) { return; } - this.pdfViewer.increaseScale(steps, { + this.pdfViewer.increaseScale({ drawingDelay: AppOptions.get("defaultZoomDelay"), + steps, }); }, @@ -664,8 +671,9 @@ const PDFViewerApplication = { if (this.pdfViewer.isInPresentationMode) { return; } - this.pdfViewer.decreaseScale(steps, { + this.pdfViewer.decreaseScale({ drawingDelay: AppOptions.get("defaultZoomDelay"), + steps, }); }, @@ -676,6 +684,23 @@ const PDFViewerApplication = { this.pdfViewer.currentScaleValue = DEFAULT_SCALE_VALUE; }, + rescale(scaleFactor, options) { + if (this.pdfViewer.isInPresentationMode || scaleFactor === 1) { + return; + } + if (scaleFactor < 1) { + this.pdfViewer.decreaseScale({ + drawingDelay: AppOptions.get("defaultZoomDelay"), + scaleFactor, + }); + } else { + this.pdfViewer.increaseScale({ + drawingDelay: AppOptions.get("defaultZoomDelay"), + scaleFactor, + }); + } + }, + get pagesCount() { return this.pdfDocument ? this.pdfDocument.numPages : 0; }, @@ -696,6 +721,10 @@ const PDFViewerApplication = { return shadow(this, "supportsFullscreen", document.fullscreenEnabled); }, + get supportsPinchToZoom() { + return this.externalServices.supportsPinchToZoom; + }, + get supportsIntegratedFind() { return this.externalServices.supportsIntegratedFind; }, @@ -1915,6 +1944,12 @@ const PDFViewerApplication = { window.addEventListener("touchstart", webViewerTouchStart, { passive: false, }); + window.addEventListener("touchmove", webViewerTouchMove, { + passive: false, + }); + window.addEventListener("touchend", webViewerTouchEnd, { + passive: false, + }); window.addEventListener("click", webViewerClick); window.addEventListener("keydown", webViewerKeyDown); window.addEventListener("resize", _boundEvents.windowResize); @@ -1997,6 +2032,12 @@ const PDFViewerApplication = { window.removeEventListener("touchstart", webViewerTouchStart, { passive: false, }); + window.removeEventListener("touchmove", webViewerTouchMove, { + passive: false, + }); + window.removeEventListener("touchend", webViewerTouchEnd, { + passive: false, + }); window.removeEventListener("click", webViewerClick); window.removeEventListener("keydown", webViewerKeyDown); window.removeEventListener("resize", _boundEvents.windowResize); @@ -2030,6 +2071,20 @@ const PDFViewerApplication = { return wholeTicks; }, + accumulateTouchTicks(ticks) { + // If the scroll direction changed, reset the accumulated touch ticks. + if ( + (this._touchUnusedTicks > 0 && ticks < 0) || + (this._touchUnusedTicks < 0 && ticks > 0) + ) { + this._touchUnusedTicks = 0; + } + this._touchUnusedTicks += ticks; + const wholeTicks = Math.trunc(this._touchUnusedTicks); + this._touchUnusedTicks -= wholeTicks; + return wholeTicks; + }, + /** * 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 @@ -2674,16 +2729,96 @@ function webViewerWheel(evt) { function webViewerTouchStart(evt) { if (evt.touches.length > 1) { - // Disable touch-based zooming, because the entire UI bits gets zoomed and - // that doesn't look great. If we do want to have a good touch-based - // zooming experience, we need to implement smooth zoom capability (probably - // using a CSS transform for faster visual response, followed by async - // re-rendering at the final zoom level) and do gesture detection on the - // touchmove events to drive it. Or if we want to settle for a less good - // experience we can make the touchmove events drive the existing step-zoom - // behaviour that the ctrl+mousewheel path takes. evt.preventDefault(); + + if (evt.touches.length !== 2) { + PDFViewerApplication._touchInfo = null; + return; + } + + const [touch0, touch1] = evt.touches; + PDFViewerApplication._touchInfo = { + centerX: (touch0.pageX + touch1.pageX) / 2, + centerY: (touch0.pageY + touch1.pageY) / 2, + distance: + Math.hypot(touch0.pageX - touch1.pageX, touch0.pageY - touch1.pageY) || + 1, + }; + } +} + +function webViewerTouchMove(evt) { + if (!PDFViewerApplication._touchInfo || evt.touches.length !== 2) { + return; + } + + const { pdfViewer, _touchInfo, supportsPinchToZoom } = PDFViewerApplication; + const [touch0, touch1] = evt.touches; + const { pageX: page0X, pageY: page0Y } = touch0; + const { pageX: page1X, pageY: page1Y } = touch1; + const distance = Math.hypot(page0X - page1X, page0Y - page1Y) || 1; + const { distance: previousDistance } = _touchInfo; + const scaleFactor = distance / previousDistance; + + if (supportsPinchToZoom && Math.abs(scaleFactor - 1) <= 1e-2) { + // Scale increase/decrease isn't significant enough. + return; + } + + const { centerX, centerY } = _touchInfo; + const diff0X = page0X - centerX; + const diff1X = page1X - centerX; + const diff0Y = page0Y - centerY; + const diff1Y = page1Y - centerY; + const dotProduct = diff0X * diff1X + diff0Y * diff1Y; + if (dotProduct >= 0) { + // The two touches go in almost the same direction. + return; + } + + evt.preventDefault(); + + const previousScale = pdfViewer.currentScale; + + if (supportsPinchToZoom) { + PDFViewerApplication.rescale(scaleFactor, { + drawingDelay: AppOptions.get("defaultZoomDelay"), + }); + } else { + const PIXELS_PER_LINE_SCALE = 30; + const delta = (distance - previousDistance) / PIXELS_PER_LINE_SCALE; + const ticks = PDFViewerApplication.accumulateTouchTicks(delta); + if (ticks < 0) { + PDFViewerApplication.zoomOut(-ticks); + } else if (ticks > 0) { + PDFViewerApplication.zoomIn(ticks); + } + } + + const currentScale = pdfViewer.currentScale; + if (previousScale !== currentScale) { + const scaleCorrectionFactor = currentScale / previousScale - 1; + const newCenterX = (_touchInfo.centerX = (page0X + page1X) / 2); + const newCenterY = (_touchInfo.centerY = (page0Y + page1Y) / 2); + _touchInfo.distance = distance; + + const [top, left] = pdfViewer.containerTopLeft; + const dx = newCenterX - left; + const dy = newCenterY - top; + pdfViewer.container.scrollLeft += dx * scaleCorrectionFactor; + pdfViewer.container.scrollTop += dy * scaleCorrectionFactor; + } +} + +function webViewerTouchEnd(evt) { + if (!PDFViewerApplication._touchInfo) { + return; } + + evt.preventDefault(); + PDFViewerApplication._touchInfo = null; + PDFViewerApplication.pdfViewer.refresh(); + PDFViewerApplication._touchUnusedTicks = 0; } function webViewerClick(evt) { diff --git a/web/firefoxcom.js b/web/firefoxcom.js index 69a56257c6c7f..dfe3a5925c5fd 100644 --- a/web/firefoxcom.js +++ b/web/firefoxcom.js @@ -410,6 +410,11 @@ class FirefoxExternalServices extends DefaultExternalServices { return FirefoxScripting; } + static get supportsPinchToZoom() { + const support = FirefoxCom.requestSync("supportsPinchToZoom"); + return shadow(this, "supportsPinchToZoom", support); + } + static get supportsIntegratedFind() { const support = FirefoxCom.requestSync("supportsIntegratedFind"); return shadow(this, "supportsIntegratedFind", support); diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 788b78dd46f1e..7d8b1cb071dbd 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -1992,46 +1992,77 @@ class PDFViewer { /** * Increase the current zoom level one, or more, times. - * @param {number} [steps] - Defaults to zooming once. * @param {Object|null} [options] */ - increaseScale(steps = 1, options = null) { + increaseScale(options = null) { if (!this.pdfDocument) { return; } - let newScale = this._currentScale; - do { - newScale = (newScale * DEFAULT_SCALE_DELTA).toFixed(2); - newScale = Math.ceil(newScale * 10) / 10; - newScale = Math.min(MAX_SCALE, newScale); - } while (--steps > 0 && newScale < MAX_SCALE); options ||= Object.create(null); - options.noScroll = false; + + let newScale; + if (options.scaleFactor) { + newScale = Math.min( + MAX_SCALE, + Math.round(this._currentScale * options.scaleFactor * 100) / 100 + ); + } else { + let steps = options.steps ?? 1; + newScale = this._currentScale; + do { + newScale = (newScale * DEFAULT_SCALE_DELTA).toFixed(2); + newScale = Math.ceil(newScale * 10) / 10; + newScale = Math.min(MAX_SCALE, newScale); + } while (--steps > 0 && newScale < MAX_SCALE); + + options.noScroll = false; + } this._setScale(newScale, options); } /** * Decrease the current zoom level one, or more, times. - * @param {number} [steps] - Defaults to zooming once. * @param {Object|null} [options] */ - decreaseScale(steps = 1, options = null) { + decreaseScale(options = null) { if (!this.pdfDocument) { return; } - let newScale = this._currentScale; - do { - newScale = (newScale / DEFAULT_SCALE_DELTA).toFixed(2); - newScale = Math.floor(newScale * 10) / 10; - newScale = Math.max(MIN_SCALE, newScale); - } while (--steps > 0 && newScale > MIN_SCALE); options ||= Object.create(null); - options.noScroll = false; + + let newScale; + if (options.scaleFactor) { + newScale = Math.max( + MIN_SCALE, + Math.round(this._currentScale * options.scaleFactor * 100) / 100 + ); + } else { + let steps = options.steps ?? 1; + newScale = this._currentScale; + do { + newScale = (newScale / DEFAULT_SCALE_DELTA).toFixed(2); + newScale = Math.floor(newScale * 10) / 10; + newScale = Math.max(MIN_SCALE, newScale); + } while (--steps > 0 && newScale > MIN_SCALE); + + options.noScroll = false; + } this._setScale(newScale, options); } + rescale(scaleFactor, options) { + const newScaleValue = Math.min( + Math.max( + MIN_SCALE, + Math.round(this._currentScale * scaleFactor * 100) / 100 + ), + MAX_SCALE + ); + this._setScale(newScaleValue, options); + } + #updateContainerHeightCss(height = this.container.clientHeight) { if (height !== this.#previousContainerHeight) { this.#previousContainerHeight = height;