@internetarchive/bookreader
Version:
The Internet Archive BookReader.
313 lines (271 loc) • 9.39 kB
JavaScript
// @ts-check
import interact from 'interactjs';
import { isIOS, isSamsungInternet } from '../util/browserSniffing.js';
import { sleep } from './utils.js';
/** @typedef {import('./utils/HTMLDimensionsCacher.js').HTMLDimensionsCacher} HTMLDimensionsCacher */
/**
* @typedef {object} SmoothZoomable
* @property {HTMLElement} $container
* @property {HTMLElement} $visibleWorld
* @property {import("./options.js").AutoFitValues} autoFit
* @property {number} scale
* @property {HTMLDimensionsCacher} htmlDimensionsCacher
* @property {function(): void} [attachScrollListeners]
* @property {function(): void} [detachScrollListeners]
*/
/** 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, clientX: number, clientY: number }}} */
this.lastEvent = null;
this.attached = false;
/** @type {function(function(): void): any} */
this.bufferFn = window.requestAnimationFrame.bind(window);
}
attach() {
if (this.attached) return;
this.attachCtrlZoom();
// 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._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.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;
}
detach() {
this.detachCtrlZoom();
// GestureEvents work only on Safari; they interfere with Hammer,
// so block them.
this.mode.$container.removeEventListener('gesturestart', this._pinchStart);
this.mode.$container.removeEventListener('gesturechange', this._preventEvent);
this.mode.$container.removeEventListener('gestureend', this._pinchEnd);
this.touchesMonitor?.detach?.();
// The pinch listeners
this.interact.unset();
interact.removeDocument(document);
this.attached = false;
}
/** @param {Event} ev */
_preventEvent = (ev) => {
ev.preventDefault();
return false;
}
_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");
this.mode.$visibleWorld.style.willChange = "transform";
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, clientX: number, clientY: number }}} e */
_pinchMove = async (e) => {
if (!this.pinching) return;
this.lastEvent = {
scale: e.scale,
clientX: e.clientX,
clientY: e.clientY,
};
if (!this.pinchMoveFrame) {
// Buffer these events; only update the scale when request animation fires
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.scaleCenter = { x: 0.5, y: 0.5 };
this.oldScale = 1;
this.mode.$visibleWorld.classList.remove("BRsmooth-zooming");
this.mode.$visibleWorld.style.willChange = "auto";
this.attachCtrlZoom();
this.mode.attachScrollListeners?.();
}
_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 */
attachCtrlZoom() {
window.addEventListener("wheel", this._handleCtrlWheel, { passive: false });
}
/** @private */
detachCtrlZoom() {
window.removeEventListener("wheel", this._handleCtrlWheel);
}
/** @param {WheelEvent} ev **/
_handleCtrlWheel = (ev) => {
if (!ev.ctrlKey) return;
ev.preventDefault();
const zoomMultiplier =
// Zooming on macs was painfully slow; likely due to their better
// trackpads. Give them a higher zoom rate.
/Mac/i.test(navigator.platform)
? 0.045
: // This worked well for me on Windows
0.03;
// Zoom around the cursor
this.updateScaleCenter(ev);
this.mode.autoFit = "none";
this.mode.scale *= 1 - Math.sign(ev.deltaY) * zoomMultiplier;
}
/**
* @param {object} param0
* @param {number} param0.clientX
* @param {number} param0.clientY
*/
updateScaleCenter({ clientX, clientY }) {
const bc = this.mode.htmlDimensionsCacher.boundingClientRect;
this.scaleCenter = {
x: (clientX - bc.left) / this.mode.htmlDimensionsCacher.clientWidth,
y: (clientY - bc.top) / this.mode.htmlDimensionsCacher.clientHeight,
};
}
/**
* @param {number} newScale
* @param {number} oldScale
*/
updateViewportOnZoom(newScale, oldScale) {
const container = this.mode.$container;
const { scrollTop: T, scrollLeft: L } = container;
const W = this.mode.htmlDimensionsCacher.clientWidth;
const H = this.mode.htmlDimensionsCacher.clientHeight;
// Scale factor change
const F = newScale / oldScale;
// Where in the viewport the zoom is centered on
const XPOS = this.scaleCenter.x;
const YPOS = this.scaleCenter.y;
const oldCenter = {
x: L + XPOS * W,
y: T + YPOS * H,
};
const newCenter = {
x: F * oldCenter.x,
y: F * oldCenter.y,
};
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;
}
}