diff --git a/package-lock.json b/package-lock.json index dc0ac17b3..12190dded 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,8 @@ "eslint": "^7.32.0", "eslint-plugin-no-jquery": "^2.7.0", "eslint-plugin-testcafe": "^0.2.1", - "hammerjs": "^2.0.8", "http-server": "14.1.1", + "interactjs": "^1.10.18", "iso-language-codes": "1.1.0", "jest": "29.6.2", "jest-environment-jsdom": "^29.4.3", @@ -2127,6 +2127,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@interactjs/types": { + "version": "1.10.18", + "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.18.tgz", + "integrity": "sha512-3a+2Kx6PhA60ekxImRQJl+EyT4lD0/kd3/PveyaLtgfNxkxnSWdUq7Ixo3Y/t1lon4EqVGZQgp+qj/QNaEs6qA==", + "dev": true + }, "node_modules/@internetarchive/field-parsers": { "version": "0.1.1", "license": "AGPL-3.0-only" @@ -8021,14 +8027,6 @@ "lodash": "^4.17.15" } }, - "node_modules/hammerjs": { - "version": "2.0.8", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/har-schema": { "version": "2.0.0", "dev": true, @@ -8557,6 +8555,15 @@ "dev": true, "license": "ISC" }, + "node_modules/interactjs": { + "version": "1.10.18", + "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.18.tgz", + "integrity": "sha512-ho+Qgr5U3b3oz23Iv7MkIZGoWaTsSCRnrCL34Dtjzs5eFghwpESJeiPj9RhYKc/SgRJL9anR+2OQxFsCg4PmLA==", + "dev": true, + "dependencies": { + "@interactjs/types": "1.10.18" + } + }, "node_modules/interpret": { "version": "3.1.1", "dev": true, @@ -17299,6 +17306,12 @@ "version": "1.2.1", "dev": true }, + "@interactjs/types": { + "version": "1.10.18", + "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.18.tgz", + "integrity": "sha512-3a+2Kx6PhA60ekxImRQJl+EyT4lD0/kd3/PveyaLtgfNxkxnSWdUq7Ixo3Y/t1lon4EqVGZQgp+qj/QNaEs6qA==", + "dev": true + }, "@internetarchive/field-parsers": { "version": "0.1.1" }, @@ -21454,10 +21467,6 @@ "lodash": "^4.17.15" } }, - "hammerjs": { - "version": "2.0.8", - "dev": true - }, "har-schema": { "version": "2.0.0", "dev": true @@ -21796,6 +21805,15 @@ "version": "1.3.8", "dev": true }, + "interactjs": { + "version": "1.10.18", + "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.18.tgz", + "integrity": "sha512-ho+Qgr5U3b3oz23Iv7MkIZGoWaTsSCRnrCL34Dtjzs5eFghwpESJeiPj9RhYKc/SgRJL9anR+2OQxFsCg4PmLA==", + "dev": true, + "requires": { + "@interactjs/types": "1.10.18" + } + }, "interpret": { "version": "3.1.1", "dev": true diff --git a/package.json b/package.json index 403cd2d64..c32004fd0 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,8 @@ "eslint": "^7.32.0", "eslint-plugin-no-jquery": "^2.7.0", "eslint-plugin-testcafe": "^0.2.1", - "hammerjs": "^2.0.8", "http-server": "14.1.1", + "interactjs": "^1.10.18", "iso-language-codes": "1.1.0", "jest": "29.6.2", "jest-environment-jsdom": "^29.4.3", diff --git a/src/BookReader/Mode1Up.js b/src/BookReader/Mode1Up.js index 0a0d27678..8e93acf1e 100644 --- a/src/BookReader/Mode1Up.js +++ b/src/BookReader/Mode1Up.js @@ -51,7 +51,7 @@ export class Mode1Up { new DragScrollable(this.mode1UpLit, { preventDefault: true, dragSelector: '.br-mode-1up__visible-world', - // Only handle mouse events; let browser/HammerJS handle touch + // Only handle mouse events; let browser/interact.js handle touch dragstart: 'mousedown', dragcontinue: 'mousemove', dragend: 'mouseup', diff --git a/src/BookReader/Mode1UpLit.js b/src/BookReader/Mode1UpLit.js index 2a1bd03a0..370d3b265 100644 --- a/src/BookReader/Mode1UpLit.js +++ b/src/BookReader/Mode1UpLit.js @@ -47,9 +47,6 @@ export class Mode1UpLit extends LitElement { @property({ type: Number }) scale = 1; - /** Position (in unit-less, [0, 1] coordinates) in client to scale around */ - @property({ type: Object }) - scaleCenter = { x: 0.5, y: 0.5 }; /************** VIRTUAL-SCROLLING PROPERTIES **************/ diff --git a/src/BookReader/Mode2UpLit.js b/src/BookReader/Mode2UpLit.js index 200fb5af7..e542a4f20 100644 --- a/src/BookReader/Mode2UpLit.js +++ b/src/BookReader/Mode2UpLit.js @@ -41,10 +41,6 @@ export class Mode2UpLit extends LitElement { initialScale = 1; - /** Position (in unit-less, [0, 1] coordinates) in client to scale around */ - @property({ type: Object }) - scaleCenter = { x: 0.5, y: 0.5 }; - /** @type {import('./options').AutoFitValues} */ @property({ type: String }) autoFit = 'auto'; diff --git a/src/BookReader/ModeSmoothZoom.js b/src/BookReader/ModeSmoothZoom.js index 9b38f13c2..f723da7dd 100644 --- a/src/BookReader/ModeSmoothZoom.js +++ b/src/BookReader/ModeSmoothZoom.js @@ -1,5 +1,7 @@ // @ts-check -import Hammer from "hammerjs"; +import interact from 'interactjs'; +import { isIOS, isSamsungInternet } from '../util/browserSniffing.js'; +import { sleep } from './utils.js'; /** @typedef {import('./utils/HTMLDimensionsCacher.js').HTMLDimensionsCacher} HTMLDimensionsCacher */ /** @@ -8,7 +10,6 @@ import Hammer from "hammerjs"; * @property {HTMLElement} $visibleWorld * @property {import("./options.js").AutoFitValues} autoFit * @property {number} scale - * @property {{ x: number, y: number }} scaleCenter * @property {HTMLDimensionsCacher} htmlDimensionsCacher * @property {function(): void} [attachScrollListeners] * @property {function(): void} [detachScrollListeners] @@ -16,31 +17,27 @@ import Hammer from "hammerjs"; /** Manages pinch-zoom, ctrl-wheel, and trackpad pinch smooth zooming. */ export class ModeSmoothZoom { + /** Position (in unit-less, [0, 1] coordinates) in client to scale around */ + scaleCenter = { x: 0.5, y: 0.5 }; + /** @param {SmoothZoomable} mode */ constructor(mode) { /** @type {SmoothZoomable} */ this.mode = mode; + /** Whether a pinch is currently happening */ + this.pinching = false; /** Non-null when a scale has been enqueued/is being processed by the buffer function */ this.pinchMoveFrame = null; /** Promise for the current/enqueued pinch move frame. Resolves when it is complete. */ this.pinchMoveFramePromise = Promise.resolve(); this.oldScale = 1; - /** @type {{ scale: number, center: { x: number, y: number }}} */ + /** @type {{ scale: number, clientX: number, clientY: number }}} */ this.lastEvent = null; this.attached = false; /** @type {function(function(): void): any} */ this.bufferFn = window.requestAnimationFrame.bind(window); - - // Hammer.js by default set userSelect to None; we don't want that! - // TODO: Is there any way to do this not globally on Hammer? - delete Hammer.defaults.cssProps.userSelect; - this.hammer = new Hammer.Manager(this.mode.$container, { - touchAction: "pan-x pan-y", - }); - - this.hammer.add(new Hammer.Pinch()); } attach() { @@ -48,17 +45,44 @@ export class ModeSmoothZoom { this.attachCtrlZoom(); - // GestureEvents work only on Safari; they interfere with Hammer, - // so block them. - this.mode.$container.addEventListener('gesturestart', this._preventEvent); + // GestureEvents work only on Safari; they're too glitchy to use + // fully, but they can sometimes help error correct when interact + // misses an end/start event on Safari due to Safari bugs. + this.mode.$container.addEventListener('gesturestart', this._pinchStart); this.mode.$container.addEventListener('gesturechange', this._preventEvent); - this.mode.$container.addEventListener('gestureend', this._preventEvent); + this.mode.$container.addEventListener('gestureend', this._pinchEnd); + + if (isIOS()) { + this.touchesMonitor = new TouchesMonitor(this.mode.$container); + this.touchesMonitor.attach(); + } + + this.mode.$container.style.touchAction = "pan-x pan-y"; // The pinch listeners - this.hammer.on("pinchstart", this._pinchStart); - this.hammer.on("pinchmove", this._pinchMove); - this.hammer.on("pinchend", this._pinchEnd); - this.hammer.on("pinchcancel", this._pinchCancel); + this.interact = interact(this.mode.$container); + this.interact.gesturable({ + listeners: { + start: this._pinchStart, + end: this._pinchEnd, + } + }); + if (isSamsungInternet()) { + // Samsung internet pinch-zoom will not work unless we disable + // all touch actions. So use interact.js' built-in drag support + // to handle moving on that browser. + this.mode.$container.style.touchAction = "none"; + this.interact + .draggable({ + inertia: { + resistance: 2, + minSpeed: 100, + allowResume: true, + }, + listeners: { move: this._dragMove } + }); + } + this.attached = true; } @@ -68,15 +92,15 @@ export class ModeSmoothZoom { // GestureEvents work only on Safari; they interfere with Hammer, // so block them. - this.mode.$container.removeEventListener('gesturestart', this._preventEvent); + this.mode.$container.removeEventListener('gesturestart', this._pinchStart); this.mode.$container.removeEventListener('gesturechange', this._preventEvent); - this.mode.$container.removeEventListener('gestureend', this._preventEvent); + this.mode.$container.removeEventListener('gestureend', this._pinchEnd); + + this.touchesMonitor?.detach?.(); // The pinch listeners - this.hammer.off("pinchstart", this._pinchStart); - this.hammer.off("pinchmove", this._pinchMove); - this.hammer.off("pinchend", this._pinchEnd); - this.hammer.off("pinchcancel", this._pinchCancel); + this.interact.unset(); + interact.removeDocument(document); this.attached = false; } @@ -87,7 +111,16 @@ export class ModeSmoothZoom { return false; } - _pinchStart = () => { + _pinchStart = async () => { + // Safari calls gesturestart twice! + if (this.pinching) return; + if (isIOS()) { + // Safari sometimes causes a pinch to trigger when there's only one touch! + await sleep(0); // touches monitor can receive the touch event late + if (this.touchesMonitor.touches < 2) return; + } + this.pinching = true; + // Do this in case the pinchend hasn't fired yet. this.oldScale = 1; this.mode.$visibleWorld.classList.add("BRsmooth-zooming"); @@ -95,37 +128,44 @@ export class ModeSmoothZoom { this.mode.autoFit = "none"; this.detachCtrlZoom(); this.mode.detachScrollListeners?.(); + + this.interact.gesturable({ + listeners: { + start: this._pinchStart, + move: this._pinchMove, + end: this._pinchEnd, + } + }); } - /** @param {{ scale: number, center: { x: number, y: number }}} e */ + /** @param {{ scale: number, clientX: number, clientY: number }}} e */ _pinchMove = async (e) => { - this.lastEvent = e; + if (!this.pinching) return; + this.lastEvent = { + scale: e.scale, + clientX: e.clientX, + clientY: e.clientY, + }; if (!this.pinchMoveFrame) { - let pinchMoveFramePromiseRes = null; - this.pinchMoveFramePromise = new Promise( - (res) => (pinchMoveFramePromiseRes = res) - ); - // Buffer these events; only update the scale when request animation fires - this.pinchMoveFrame = this.bufferFn(() => { - this.updateScaleCenter({ - clientX: this.lastEvent.center.x, - clientY: this.lastEvent.center.y, - }); - this.mode.scale *= this.lastEvent.scale / this.oldScale; - this.oldScale = this.lastEvent.scale; - this.pinchMoveFrame = null; - pinchMoveFramePromiseRes(); - }); + this.pinchMoveFrame = this.bufferFn(this._drawPinchZoomFrame); } } _pinchEnd = async () => { + if (!this.pinching) return; + this.pinching = false; + this.interact.gesturable({ + listeners: { + start: this._pinchStart, + end: this._pinchEnd, + } + }); // Want this to happen after the pinchMoveFrame, // if one is in progress; otherwise setting oldScale // messes up the transform. await this.pinchMoveFramePromise; - this.mode.scaleCenter = { x: 0.5, y: 0.5 }; + this.scaleCenter = { x: 0.5, y: 0.5 }; this.oldScale = 1; this.mode.$visibleWorld.classList.remove("BRsmooth-zooming"); this.mode.$visibleWorld.style.willChange = "auto"; @@ -133,10 +173,42 @@ export class ModeSmoothZoom { this.mode.attachScrollListeners?.(); } - _pinchCancel = async () => { - // iOS fires pinchcancel ~randomly; it looks like it sometimes - // thinks the pinch becomes a pan, at which point it cancels? - await this._pinchEnd(); + _drawPinchZoomFrame = async () => { + // Because of the buffering/various timing locks, + // this can be called after the pinch has ended, which + // results in a janky zoom after the pinch. + if (!this.pinching) { + this.pinchMoveFrame = null; + return; + } + + this.mode.$container.style.overflow = "hidden"; + this.pinchMoveFramePromiseRes = null; + this.pinchMoveFramePromise = new Promise( + (res) => (this.pinchMoveFramePromiseRes = res) + ); + this.updateScaleCenter({ + clientX: this.lastEvent.clientX, + clientY: this.lastEvent.clientY, + }); + const curScale = this.mode.scale; + const newScale = curScale * this.lastEvent.scale / this.oldScale; + + if (curScale != newScale) { + this.mode.scale = newScale; + await this.pinchMoveFramePromise; + } + this.mode.$container.style.overflow = "auto"; + this.oldScale = this.lastEvent.scale; + this.pinchMoveFrame = null; + } + + _dragMove = async (e) => { + if (this.pinching) { + await this._pinchEnd(); + } + this.mode.$container.scrollTop -= e.dy; + this.mode.$container.scrollLeft -= e.dx; } /** @private */ @@ -174,7 +246,7 @@ export class ModeSmoothZoom { */ updateScaleCenter({ clientX, clientY }) { const bc = this.mode.htmlDimensionsCacher.boundingClientRect; - this.mode.scaleCenter = { + this.scaleCenter = { x: (clientX - bc.left) / this.mode.htmlDimensionsCacher.clientWidth, y: (clientY - bc.top) / this.mode.htmlDimensionsCacher.clientHeight, }; @@ -194,8 +266,8 @@ export class ModeSmoothZoom { const F = newScale / oldScale; // Where in the viewport the zoom is centered on - const XPOS = this.mode.scaleCenter.x; - const YPOS = this.mode.scaleCenter.y; + const XPOS = this.scaleCenter.x; + const YPOS = this.scaleCenter.y; const oldCenter = { x: L + XPOS * W, y: T + YPOS * H, @@ -207,5 +279,34 @@ export class ModeSmoothZoom { container.scrollTop = newCenter.y - YPOS * H; container.scrollLeft = newCenter.x - XPOS * W; + this.pinchMoveFramePromiseRes?.(); + } +} + +export class TouchesMonitor { + /** + * @param {HTMLElement} container + */ + constructor(container) { + /** @type {HTMLElement} */ + this.container = container; + this.touches = 0; + } + + attach() { + this.container.addEventListener("touchstart", this._updateTouchCount); + this.container.addEventListener("touchend", this._updateTouchCount); + } + + detach() { + this.container.removeEventListener("touchstart", this._updateTouchCount); + this.container.removeEventListener("touchend", this._updateTouchCount); + } + + /** + * @param {TouchEvent} ev + */ + _updateTouchCount = (ev) => { + this.touches = ev.touches.length; } } diff --git a/src/util/browserSniffing.js b/src/util/browserSniffing.js index 3b37f4d27..76812853b 100644 --- a/src/util/browserSniffing.js +++ b/src/util/browserSniffing.js @@ -28,3 +28,25 @@ export function isFirefox(userAgent = navigator.userAgent) { export function isSafari(userAgent = navigator.userAgent) { return /safari/i.test(userAgent) && !/chrome|chromium/i.test(userAgent); } + +/** + * Checks whether the current browser is iOS (and hence iOS webkit) + * @return {boolean} + */ +export function isIOS() { + // We can't just check the userAgent because as of iOS 13, + // the userAgent is the same as desktop Safari because + // they wanted iPad's to be served the same version of websites + // as desktops. + return 'ongesturestart' in window && navigator.maxTouchPoints > 0; +} + +/** + * Checks whether the current browser is Samsung Internet + * https://stackoverflow.com/a/40684162/2317712 + * @param {string} [userAgent] + * @return {boolean} + */ +export function isSamsungInternet(userAgent = navigator.userAgent) { + return /SamsungBrowser/i.test(userAgent); +} diff --git a/tests/jest/BookReader/ModeSmoothZoom.test.js b/tests/jest/BookReader/ModeSmoothZoom.test.js index e983025ea..6051763a2 100644 --- a/tests/jest/BookReader/ModeSmoothZoom.test.js +++ b/tests/jest/BookReader/ModeSmoothZoom.test.js @@ -1,6 +1,7 @@ import sinon from 'sinon'; -import { EventTargetSpy } from '../utils.js'; -import { ModeSmoothZoom } from '@/src/BookReader/ModeSmoothZoom.js'; +import interact from 'interactjs'; +import { EventTargetSpy, afterEventLoop } from '../utils.js'; +import { ModeSmoothZoom, TouchesMonitor } from '@/src/BookReader/ModeSmoothZoom.js'; /** @typedef {import('@/src/BookReader/ModeSmoothZoom.js').SmoothZoomable} SmoothZoomable */ /** @@ -22,33 +23,32 @@ function dummy_mode(overrides = {}) { }; } -afterEach(() => sinon.restore()); +afterEach(() => { + sinon.restore(); + try { + interact.removeDocument(document); + } catch (e) {} +}); describe('ModeSmoothZoom', () => { - test('preventsDefault on iOS-only gesture events', () => { + test('handle iOS-only gesture events', () => { const mode = dummy_mode(); const msz = new ModeSmoothZoom(mode); + sinon.stub(msz, '_pinchStart'); + sinon.stub(msz, '_pinchMove'); + sinon.stub(msz, '_pinchEnd'); + msz.attach(); - for (const event_name of ['gesturestart', 'gesturechange', 'gestureend']) { - const ev = new Event(event_name, {}); - const prevDefaultSpy = sinon.spy(ev, 'preventDefault'); - mode.$container.dispatchEvent(ev); - expect(prevDefaultSpy.callCount).toBe(1); - } - }); - test('pinchCancel alias for pinchEnd', () => { - const mode = dummy_mode(); - const msz = new ModeSmoothZoom(mode); - const pinchEndSpy = sinon.spy(msz, '_pinchEnd'); - msz._pinchStart(); - msz._pinchCancel(); - expect(pinchEndSpy.callCount).toBe(1); + const gesturestart = new Event('gesturestart', {}); + mode.$container.dispatchEvent(gesturestart); + expect(msz._pinchStart.callCount).toBe(1); }); test('sets will-change', async () => { const mode = dummy_mode(); const msz = new ModeSmoothZoom(mode); + msz.attach(); expect(mode.$visibleWorld.style.willChange).toBeFalsy(); msz._pinchStart(); expect(mode.$visibleWorld.style.willChange).toBe('transform'); @@ -59,6 +59,7 @@ describe('ModeSmoothZoom', () => { test('pinch move updates scale', () => { const mode = dummy_mode(); const msz = new ModeSmoothZoom(mode); + msz.attach(); // disable buffering msz.bufferFn = (callback) => callback(); msz._pinchStart(); @@ -79,48 +80,47 @@ describe('ModeSmoothZoom', () => { } }); const msz = new ModeSmoothZoom(mode); - expect(mode.scaleCenter).toEqual({ x: 0.5, y: 0.5 }); + expect(msz.scaleCenter).toEqual({ x: 0.5, y: 0.5 }); msz.updateScaleCenter({ clientX: 85, clientY: 110 }); - expect(mode.scaleCenter).toEqual({ x: 0.4, y: 0.6 }); + expect(msz.scaleCenter).toEqual({ x: 0.4, y: 0.6 }); }); - test('detaches all listeners', () => { + test('detaches all listeners', async () => { const mode = dummy_mode(); const msz = new ModeSmoothZoom(mode); + + const documentEventSpy = EventTargetSpy.wrap(document); const containerEventSpy = EventTargetSpy.wrap(mode.$container); const visibleWorldSpy = EventTargetSpy.wrap(mode.$visibleWorld); - const hammerEventSpy = new EventTargetSpy(); - msz.hammer.on = hammerEventSpy.addEventListener.bind(hammerEventSpy); - msz.hammer.off = hammerEventSpy.removeEventListener.bind(hammerEventSpy); msz.attach(); + await afterEventLoop(); + expect(documentEventSpy._totalListenerCount).toBeGreaterThan(0); expect(containerEventSpy._totalListenerCount).toBeGreaterThan(0); - expect(hammerEventSpy._totalListenerCount).toBeGreaterThan(0); msz.detach(); + expect(documentEventSpy._totalListenerCount).toBe(0); expect(containerEventSpy._totalListenerCount).toBe(0); expect(visibleWorldSpy._totalListenerCount).toBe(0); - expect(hammerEventSpy._totalListenerCount).toBe(0); }); test('attach can be called twice without double attachments', () => { const mode = dummy_mode(); const msz = new ModeSmoothZoom(mode); + + const documentEventSpy = EventTargetSpy.wrap(document); const containerEventSpy = EventTargetSpy.wrap(mode.$container); const visibleWorldSpy = EventTargetSpy.wrap(mode.$visibleWorld); - const hammerEventSpy = new EventTargetSpy(); - msz.hammer.on = hammerEventSpy.addEventListener.bind(hammerEventSpy); - msz.hammer.off = hammerEventSpy.removeEventListener.bind(hammerEventSpy); - msz.attach(); + msz.attach(); + const documentListenersCount = documentEventSpy._totalListenerCount; const containerListenersCount = containerEventSpy._totalListenerCount; const visibleWorldListenersCount = visibleWorldSpy._totalListenerCount; - const hammerListenersCount = hammerEventSpy._totalListenerCount; msz.attach(); + expect(documentEventSpy._totalListenerCount).toBe(documentListenersCount); expect(containerEventSpy._totalListenerCount).toBe(containerListenersCount); expect(visibleWorldSpy._totalListenerCount).toBe(visibleWorldListenersCount); - expect(hammerEventSpy._totalListenerCount).toBe(hammerListenersCount); }); describe('_handleCtrlWheel', () => { @@ -173,3 +173,46 @@ describe('ModeSmoothZoom', () => { }); }); }); + + +describe("TouchesMonitor", () => { + /** @type {HTMLElement} */ + let container; + /** @type {TouchesMonitor} */ + let monitor; + + beforeEach(() => { + container = document.createElement("div"); + monitor = new TouchesMonitor(container); + }); + + afterEach(() => { + monitor.detach(); + }); + + test("should start with 0 touches", () => { + expect(monitor.touches).toBe(0); + }); + + test("should update touch count on touch events", () => { + monitor.attach(); + container.dispatchEvent(new TouchEvent("touchstart", { touches: [{}] })); + expect(monitor.touches).toBe(1); + + container.dispatchEvent(new TouchEvent("touchstart", { touches: [{}, {}] })); + expect(monitor.touches).toBe(2); + + container.dispatchEvent(new TouchEvent("touchend", { touches: [{}] })); + expect(monitor.touches).toBe(1); + + container.dispatchEvent(new TouchEvent("touchend", { touches: [] })); + }); + + test("should detach all listeners", () => { + const spy = EventTargetSpy.wrap(container); + monitor.attach(); + expect(spy._totalListenerCount).toBeGreaterThan(0); + monitor.detach(); + expect(spy._totalListenerCount).toBe(0); + }); +});