diff --git a/src/display/touch_manager.js b/src/display/touch_manager.js new file mode 100644 index 000000000..00b717c5b --- /dev/null +++ b/src/display/touch_manager.js @@ -0,0 +1,192 @@ +/* Copyright 2024 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 { shadow } from "../shared/util.js"; + +class TouchManager { + #container; + + #isPinching = false; + + #isPinchingStopped = null; + + #isPinchingDisabled; + + #onPinching; + + #onPinchEnd; + + #signal; + + #touchInfo = null; + + #touchManagerAC; + + #touchMoveAC = null; + + constructor({ + container, + isPinchingDisabled = null, + isPinchingStopped = null, + onPinching = null, + onPinchEnd = null, + signal, + }) { + this.#container = container; + this.#isPinchingStopped = isPinchingStopped; + this.#isPinchingDisabled = isPinchingDisabled; + this.#onPinching = onPinching; + this.#onPinchEnd = onPinchEnd; + this.#touchManagerAC = new AbortController(); + this.#signal = AbortSignal.any([signal, this.#touchManagerAC.signal]); + + container.addEventListener("touchstart", this.#onTouchStart.bind(this), { + passive: false, + signal: this.#signal, + }); + } + + get MIN_TOUCH_DISTANCE_TO_PINCH() { + // The 35 is coming from: + // https://searchfox.org/mozilla-central/source/gfx/layers/apz/src/GestureEventListener.cpp#36 + // + // The properties TouchEvent::screenX/Y are in screen CSS pixels: + // https://developer.mozilla.org/en-US/docs/Web/API/Touch/screenX#examples + // MIN_TOUCH_DISTANCE_TO_PINCH is in CSS pixels. + return shadow( + this, + "MIN_TOUCH_DISTANCE_TO_PINCH", + 35 / (window.devicePixelRatio || 1) + ); + } + + #onTouchStart(evt) { + if (this.#isPinchingDisabled?.() || evt.touches.length < 2) { + return; + } + + if (!this.#touchMoveAC) { + this.#touchMoveAC = new AbortController(); + const signal = AbortSignal.any([this.#signal, this.#touchMoveAC.signal]); + const container = this.#container; + const opt = { signal, passive: false }; + container.addEventListener( + "touchmove", + this.#onTouchMove.bind(this), + opt + ); + container.addEventListener("touchend", this.#onTouchEnd.bind(this), opt); + container.addEventListener( + "touchcancel", + this.#onTouchEnd.bind(this), + opt + ); + } + + evt.preventDefault(); + + if (evt.touches.length !== 2 || this.#isPinchingStopped?.()) { + this.#touchInfo = null; + return; + } + + let [touch0, touch1] = evt.touches; + if (touch0.identifier > touch1.identifier) { + [touch0, touch1] = [touch1, touch0]; + } + this.#touchInfo = { + touch0X: touch0.screenX, + touch0Y: touch0.screenY, + touch1X: touch1.screenX, + touch1Y: touch1.screenY, + }; + } + + #onTouchMove(evt) { + if (!this.#touchInfo || evt.touches.length !== 2) { + return; + } + + let [touch0, touch1] = evt.touches; + if (touch0.identifier > touch1.identifier) { + [touch0, touch1] = [touch1, touch0]; + } + const { screenX: screen0X, screenY: screen0Y } = touch0; + const { screenX: screen1X, screenY: screen1Y } = touch1; + const touchInfo = this.#touchInfo; + const { + touch0X: pTouch0X, + touch0Y: pTouch0Y, + touch1X: pTouch1X, + touch1Y: pTouch1Y, + } = touchInfo; + + const prevGapX = pTouch1X - pTouch0X; + const prevGapY = pTouch1Y - pTouch0Y; + const currGapX = screen1X - screen0X; + const currGapY = screen1Y - screen0Y; + + const distance = Math.hypot(currGapX, currGapY) || 1; + const pDistance = Math.hypot(prevGapX, prevGapY) || 1; + if ( + !this.#isPinching && + Math.abs(pDistance - distance) <= TouchManager.MIN_TOUCH_DISTANCE_TO_PINCH + ) { + return; + } + + touchInfo.touch0X = screen0X; + touchInfo.touch0Y = screen0Y; + touchInfo.touch1X = screen1X; + touchInfo.touch1Y = screen1Y; + + evt.preventDefault(); + + if (!this.#isPinching) { + // Start pinching. + this.#isPinching = true; + + // We return here else the first pinch is a bit too much + return; + } + + const origin = [(screen0X + screen1X) / 2, (screen0Y + screen1Y) / 2]; + this.#onPinching?.(origin, pDistance, distance); + } + + #onTouchEnd(evt) { + this.#touchMoveAC.abort(); + this.#touchMoveAC = null; + + if (!this.#touchInfo) { + return; + } + + if (this.#isPinching) { + this.#onPinchEnd?.(); + this.#isPinching = false; + } + + evt.preventDefault(); + this.#touchInfo = null; + } + + destroy() { + this.#touchManagerAC?.abort(); + this.#touchManagerAC = null; + } +} + +export { TouchManager }; diff --git a/src/pdf.js b/src/pdf.js index b94e30193..94d7547f0 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -72,6 +72,7 @@ import { DrawLayer } from "./display/draw_layer.js"; import { GlobalWorkerOptions } from "./display/worker_options.js"; import { HighlightOutliner } from "./display/editor/drawers/highlight.js"; import { TextLayer } from "./display/text_layer.js"; +import { TouchManager } from "./display/touch_manager.js"; import { XfaLayer } from "./display/xfa_layer.js"; /* eslint-disable-next-line no-unused-vars */ @@ -127,6 +128,7 @@ export { shadow, stopEvent, TextLayer, + TouchManager, UnexpectedResponseException, Util, VerbosityLevel, diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index 35681a52b..d4a32c48a 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -63,6 +63,7 @@ import { DOMSVGFactory } from "../../src/display/svg_factory.js"; import { DrawLayer } from "../../src/display/draw_layer.js"; import { GlobalWorkerOptions } from "../../src/display/worker_options.js"; import { TextLayer } from "../../src/display/text_layer.js"; +import { TouchManager } from "../../src/display/touch_manager.js"; import { XfaLayer } from "../../src/display/xfa_layer.js"; const expectedAPI = Object.freeze({ @@ -105,6 +106,7 @@ const expectedAPI = Object.freeze({ shadow, stopEvent, TextLayer, + TouchManager, UnexpectedResponseException, Util, VerbosityLevel, diff --git a/web/app.js b/web/app.js index 5ea10fcfd..d23a04f2c 100644 --- a/web/app.js +++ b/web/app.js @@ -54,6 +54,7 @@ import { PDFWorker, shadow, stopEvent, + TouchManager, UnexpectedResponseException, version, } from "pdfjs-lib"; @@ -94,14 +95,6 @@ import { ViewHistory } from "./view_history.js"; const FORCE_PAGES_LOADED_TIMEOUT = 10000; // ms -// The 35 is coming from: -// https://searchfox.org/mozilla-central/source/gfx/layers/apz/src/GestureEventListener.cpp#36 -// -// The properties TouchEvent::screenX/Y are in screen CSS pixels: -// https://developer.mozilla.org/en-US/docs/Web/API/Touch/screenX#examples -// MIN_TOUCH_DISTANCE_TO_PINCH is in CSS pixels. -const MIN_TOUCH_DISTANCE_TO_PINCH = 35 / (window.devicePixelRatio || 1); - const ViewOnLoad = { UNKNOWN: -1, PREVIOUS: 0, // Default value. @@ -182,14 +175,13 @@ const PDFViewerApplication = { _saveInProgress: false, _wheelUnusedTicks: 0, _wheelUnusedFactor: 1, + _touchManager: null, _touchUnusedTicks: 0, _touchUnusedFactor: 1, _PDFBug: null, _hasAnnotationEditors: false, _title: document.title, _printAnnotationStoragePromise: null, - _touchInfo: null, - _isPinching: false, _isCtrlKeyDown: false, _caretBrowsing: null, _isScrolling: false, @@ -825,6 +817,29 @@ const PDFViewerApplication = { this.pdfViewer.currentScaleValue = DEFAULT_SCALE_VALUE; }, + touchPinchCallback(origin, prevDistance, distance) { + if (this.supportsPinchToZoom) { + const newScaleFactor = this._accumulateFactor( + this.pdfViewer.currentScale, + distance / prevDistance, + "_touchUnusedFactor" + ); + this.updateZoom(null, newScaleFactor, origin); + } else { + const PIXELS_PER_LINE_SCALE = 30; + const ticks = this._accumulateTicks( + (distance - prevDistance) / PIXELS_PER_LINE_SCALE, + "_touchUnusedTicks" + ); + this.updateZoom(ticks, null, origin); + } + }, + + touchPinchEndCallback() { + this._touchUnusedTicks = 0; + this._touchUnusedFactor = 1; + }, + get pagesCount() { return this.pdfDocument ? this.pdfDocument.numPages : 0; }, @@ -2029,6 +2044,15 @@ const PDFViewerApplication = { _windowAbortController: { signal }, } = this; + this._touchManager = new TouchManager({ + container: window, + isPinchingDisabled: () => this.pdfViewer.isInPresentationMode, + isPinchingStopped: () => this.overlayManager?.active, + onPinching: this.touchPinchCallback.bind(this), + onPinchEnd: this.touchPinchEndCallback.bind(this), + signal, + }); + function addWindowResolutionChange(evt = null) { if (evt) { pdfViewer.refresh(); @@ -2047,18 +2071,6 @@ const PDFViewerApplication = { passive: false, signal, }); - window.addEventListener("touchstart", onTouchStart.bind(this), { - passive: false, - signal, - }); - window.addEventListener("touchmove", onTouchMove.bind(this), { - passive: false, - signal, - }); - window.addEventListener("touchend", onTouchEnd.bind(this), { - passive: false, - signal, - }); window.addEventListener("click", onClick.bind(this), { signal }); window.addEventListener("keydown", onKeyDown.bind(this), { signal }); window.addEventListener("keyup", onKeyUp.bind(this), { signal }); @@ -2157,6 +2169,7 @@ const PDFViewerApplication = { unbindWindowEvents() { this._windowAbortController?.abort(); this._windowAbortController = null; + this._touchManager = null; }, /** @@ -2638,107 +2651,6 @@ function onWheel(evt) { } } -function onTouchStart(evt) { - if (this.pdfViewer.isInPresentationMode || evt.touches.length < 2) { - return; - } - evt.preventDefault(); - - if (evt.touches.length !== 2 || this.overlayManager.active) { - this._touchInfo = null; - return; - } - - let [touch0, touch1] = evt.touches; - if (touch0.identifier > touch1.identifier) { - [touch0, touch1] = [touch1, touch0]; - } - this._touchInfo = { - touch0X: touch0.screenX, - touch0Y: touch0.screenY, - touch1X: touch1.screenX, - touch1Y: touch1.screenY, - }; -} - -function onTouchMove(evt) { - if (!this._touchInfo || evt.touches.length !== 2) { - return; - } - - const { pdfViewer, _touchInfo, supportsPinchToZoom } = this; - let [touch0, touch1] = evt.touches; - if (touch0.identifier > touch1.identifier) { - [touch0, touch1] = [touch1, touch0]; - } - const { screenX: screen0X, screenY: screen0Y } = touch0; - const { screenX: screen1X, screenY: screen1Y } = touch1; - const { - touch0X: pTouch0X, - touch0Y: pTouch0Y, - touch1X: pTouch1X, - touch1Y: pTouch1Y, - } = _touchInfo; - - const prevGapX = pTouch1X - pTouch0X; - const prevGapY = pTouch1Y - pTouch0Y; - const currGapX = screen1X - screen0X; - const currGapY = screen1Y - screen0Y; - - const distance = Math.hypot(currGapX, currGapY) || 1; - const pDistance = Math.hypot(prevGapX, prevGapY) || 1; - if ( - !this._isPinching && - Math.abs(pDistance - distance) <= MIN_TOUCH_DISTANCE_TO_PINCH - ) { - return; - } - - _touchInfo.touch0X = screen0X; - _touchInfo.touch0Y = screen0Y; - _touchInfo.touch1X = screen1X; - _touchInfo.touch1Y = screen1Y; - - evt.preventDefault(); - - if (!this._isPinching) { - // Start pinching. - this._isPinching = true; - - // We return here else the first pinch is a bit too much - return; - } - - const origin = [(screen0X + screen1X) / 2, (screen0Y + screen1Y) / 2]; - if (supportsPinchToZoom) { - const newScaleFactor = this._accumulateFactor( - pdfViewer.currentScale, - distance / pDistance, - "_touchUnusedFactor" - ); - this.updateZoom(null, newScaleFactor, origin); - } else { - const PIXELS_PER_LINE_SCALE = 30; - const ticks = this._accumulateTicks( - (distance - pDistance) / PIXELS_PER_LINE_SCALE, - "_touchUnusedTicks" - ); - this.updateZoom(ticks, null, origin); - } -} - -function onTouchEnd(evt) { - if (!this._touchInfo) { - return; - } - - evt.preventDefault(); - this._touchInfo = null; - this._touchUnusedTicks = 0; - this._touchUnusedFactor = 1; - this._isPinching = false; -} - function closeSecondaryToolbar(evt) { if (!this.secondaryToolbar?.isOpen) { return; diff --git a/web/pdfjs.js b/web/pdfjs.js index 2203abc42..a9fe6ca9a 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -53,6 +53,7 @@ const { shadow, stopEvent, TextLayer, + TouchManager, UnexpectedResponseException, Util, VerbosityLevel, @@ -100,6 +101,7 @@ export { shadow, stopEvent, TextLayer, + TouchManager, UnexpectedResponseException, Util, VerbosityLevel,