Skip to content

Commit

Permalink
New: Enhanced pinch to zoom (#567)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jeremy Press committed Jan 11, 2018
1 parent efd0621 commit b59b453
Show file tree
Hide file tree
Showing 7 changed files with 572 additions and 59 deletions.
69 changes: 69 additions & 0 deletions src/lib/__tests__/util-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import 'whatwg-fetch';
import fetchMock from 'fetch-mock';
import * as util from '../util';

const sandbox = sinon.sandbox.create();

describe('lib/util', () => {
afterEach(() => {
sandbox.verifyAndRestore();
});

describe('get()', () => {
const url = 'foo?bar=bum';

Expand Down Expand Up @@ -663,4 +669,67 @@ describe('lib/util', () => {
expect(result).to.equal(2);
});
});

describe('getMidpoint()', () => {
it('should correctly calculate the midpoint', () => {
const result = util.getMidpoint(10, 10, 0, 0);
expect(result).to.deep.equal([5, 5]);
});
});

describe('getDistance()', () => {
it('should correctly calculate the distance', () => {
const result = util.getDistance(0, 0, 6, 8);
expect(result).to.equal(10);
});
});

describe('getClosestPageToPinch()', () => {
it('should find the closest page', () => {
const page1 = {
id: 1,
offsetLeft: 0,
offsetTop: 0,
scrollWidth: 0,
scrollHeight: 0
};
const page2 = {
id: 2,
offsetLeft: 100,
offsetTop: 0,
scrollWidth: 100,
scrollHeight: 0
};
const visiblePages = {
first: {
id: 1
},
last: {
id: 2
}
}

const midpointStub = sandbox.stub(document, 'querySelector');
midpointStub.onCall(0).returns(page1);
midpointStub.onCall(1).returns(page2);

sandbox.stub(util, 'getMidpoint').returns([0, 0]);
const distanceStub = sandbox.stub(util, 'getDistance').returns(100)

const result = util.getClosestPageToPinch(0, 0, visiblePages);
expect(result.id).to.equal(page1.id)
});

it('should return null if there are no pages', () => {
let result = util.getClosestPageToPinch(0, 0, null);
expect(result).to.equal(null);

result = util.getClosestPageToPinch(0, 0, {
first: null,
last: null
});

expect(result).to.equal(null);
});
});
});
64 changes: 64 additions & 0 deletions src/lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -751,3 +751,67 @@ export function pageNumberFromScroll(currentPageNum, previousScrollTop, currentP

return pageNum;
}

/**
* Calculates the midpoint from two points.
*
* @public
* @param {number} x1 - The x value of the first point
* @param {number} y1 - The y value of the first point
* @param {number} x2 - The x value of the second point
* @param {number} y2 - The y value of the second point
* @return {Array} The resulting x,y point
*/
export function getMidpoint(x1, y1, x2, y2) {
const x = (x1 + x2) / 2;
const y = (y1 + y2) / 2;
return [x, y];
}

/**
* Calculates the distance between two points.
*
* @public
* @param {number} x1 - The x value of the first point
* @param {number} y1 - The y value of the first point
* @param {number} x2 - The x value of the second point
* @param {number} y2 - The y value of the second point
* @return {number} The resulting distance
*/
export function getDistance(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}

/**
* Returns the closest visible page to a pinch event
*
* @public
* @param {number} x - The x value of pinch
* @param {number} y - The y value of the pinch
* @param {Object} visiblePages - Object of visible pages to consider
* @return {HTMLElement} The resulting page
*/
export function getClosestPageToPinch(x, y, visiblePages) {
if (!visiblePages || !visiblePages.first || !visiblePages.last) {
return null;
}

let closestPage = null;
for (let i = visiblePages.first.id, closestDistance = null; i <= visiblePages.last.id; i++) {
const page = document.querySelector(`#bp-page-${i}`);
const pageMidpoint = getMidpoint(
page.offsetLeft,
page.offsetTop,
page.offsetLeft + page.scrollWidth,
page.offsetTop + page.scrollHeight
);

const distance = getDistance(pageMidpoint[0], pageMidpoint[1], x, y);
if (!closestPage || distance < closestDistance) {
closestPage = page;
closestDistance = distance;
}
}

return closestPage;
}
169 changes: 148 additions & 21 deletions src/lib/viewers/doc/DocBaseViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
STATUS_SUCCESS
} from '../../constants';
import { checkPermission, getRepresentation } from '../../file';
import { get, createAssetUrlCreator } from '../../util';
import { get, createAssetUrlCreator, getMidpoint, getDistance, getClosestPageToPinch } from '../../util';
import { ICON_PRINT_CHECKMARK } from '../../icons/icons';
import { JS, CSS } from './docAssets';

Expand All @@ -30,13 +30,18 @@ const SAFARI_PRINT_TIMEOUT_MS = 1000; // Wait 1s before trying to print
const PRINT_DIALOG_TIMEOUT_MS = 500;
const MAX_SCALE = 10.0;
const MIN_SCALE = 0.1;
const MAX_PINCH_SCALE_VALUE = 3;
const MIN_PINCH_SCALE_VALUE = 0.25;
const MIN_PINCH_SCALE_DELTA = 0.01;
const IS_SAFARI_CLASS = 'is-safari';
const SCROLL_EVENT_THROTTLE_INTERVAL = 200;
const SCROLL_END_TIMEOUT = this.isMobile ? 500 : 250;
const RANGE_REQUEST_CHUNK_SIZE_US = 1048576; // 1MB
const RANGE_REQUEST_CHUNK_SIZE_NON_US = 524288; // 512KB
const MINIMUM_RANGE_REQUEST_FILE_SIZE_NON_US = 26214400; // 25MB
const MOBILE_MAX_CANVAS_SIZE = 2949120; // ~3MP 1920x1536
const PINCH_PAGE_CLASS = 'pinch-page';
const PINCHING_CLASS = 'pinching';

class DocBaseViewer extends BaseViewer {
//--------------------------------------------------------------------------
Expand All @@ -61,6 +66,9 @@ class DocBaseViewer extends BaseViewer {
this.enterfullscreenHandler = this.enterfullscreenHandler.bind(this);
this.exitfullscreenHandler = this.exitfullscreenHandler.bind(this);
this.throttledScrollHandler = this.getScrollHandler().bind(this);
this.pinchToZoomStartHandler = this.pinchToZoomStartHandler.bind(this);
this.pinchToZoomChangeHandler = this.pinchToZoomChangeHandler.bind(this);
this.pinchToZoomEndHandler = this.pinchToZoomEndHandler.bind(this);
}

/**
Expand Down Expand Up @@ -89,8 +97,6 @@ class DocBaseViewer extends BaseViewer {
this.viewerEl = this.docEl.appendChild(document.createElement('div'));
this.viewerEl.classList.add('pdfViewer');
this.loadTimeout = LOAD_TIMEOUT_MS;

this.scaling = false;
}

/**
Expand Down Expand Up @@ -792,15 +798,10 @@ class DocBaseViewer extends BaseViewer {
fullscreen.addListener('enter', this.enterfullscreenHandler);
fullscreen.addListener('exit', this.exitfullscreenHandler);

if (this.isMobile) {
if (Browser.isIOS()) {
this.docEl.addEventListener('gesturestart', this.mobileZoomStartHandler);
this.docEl.addEventListener('gestureend', this.mobileZoomEndHandler);
} else {
this.docEl.addEventListener('touchstart', this.mobileZoomStartHandler);
this.docEl.addEventListener('touchmove', this.mobileZoomChangeHandler);
this.docEl.addEventListener('touchend', this.mobileZoomEndHandler);
}
if (this.hasTouch) {
this.docEl.addEventListener('touchstart', this.pinchToZoomStartHandler);
this.docEl.addEventListener('touchmove', this.pinchToZoomChangeHandler);
this.docEl.addEventListener('touchend', this.pinchToZoomEndHandler);
}
}

Expand All @@ -817,15 +818,10 @@ class DocBaseViewer extends BaseViewer {
this.docEl.removeEventListener('pagechange', this.pagechangeHandler);
this.docEl.removeEventListener('scroll', this.throttledScrollHandler);

if (this.isMobile) {
if (Browser.isIOS()) {
this.docEl.removeEventListener('gesturestart', this.mobileZoomStartHandler);
this.docEl.removeEventListener('gestureend', this.mobileZoomEndHandler);
} else {
this.docEl.removeEventListener('touchstart', this.mobileZoomStartHandler);
this.docEl.removeEventListener('touchmove', this.mobileZoomChangeHandler);
this.docEl.removeEventListener('touchend', this.mobileZoomEndHandler);
}
if (this.hasTouch) {
this.docEl.removeEventListener('touchstart', this.pinchToZoomStartHandler);
this.docEl.removeEventListener('touchmove', this.pinchToZoomChangeHandler);
this.docEl.removeEventListener('touchend', this.pinchToZoomEndHandler);
}
}

Expand Down Expand Up @@ -977,6 +973,137 @@ class DocBaseViewer extends BaseViewer {
}, SCROLL_END_TIMEOUT);
}, SCROLL_EVENT_THROTTLE_INTERVAL);
}

/**
* Sets up pinch to zoom behavior by wrapping zoomed divs and determining the original pinch distance.
*
* @protected
* @param {Event} event - object
* @return {void}
*/
pinchToZoomStartHandler(event) {
if (event.touches.length < 2) {
return;
}

event.preventDefault();
event.stopPropagation();

this.isPinching = true;

// Determine the midpoint of our pinch event if it is not provided for us
const touchMidpoint =
event.pageX && event.pageY
? [event.pageX, event.pageY]
: getMidpoint(
event.touches[0].pageX,
event.touches[0].pageY,
event.touches[1].pageX,
event.touches[1].pageY
);

// Find the page closest to the pinch
const visiblePages = this.pdfViewer._getVisiblePages();
this.pinchPage = getClosestPageToPinch(
this.docEl.scrollLeft + touchMidpoint[0],
this.docEl.scrollTop + touchMidpoint[1],
visiblePages
);

// Set the scale point based on the pinch midpoint and scroll offsets
this.scaledXOffset = this.docEl.scrollLeft - this.pinchPage.offsetLeft + touchMidpoint[0];
this.scaledYOffset = this.docEl.scrollTop - this.pinchPage.offsetTop + touchMidpoint[1] + 15;

this.pinchPage.style['transform-origin'] = `${this.scaledXOffset}px ${this.scaledYOffset}px`;

// Preserve the original touch offset
this.originalXOffset = touchMidpoint[0];
this.originalYOffset = touchMidpoint[1];

// Used by non-iOS browsers that do not provide a scale value
this.originalDistance = getDistance(
event.touches[0].pageX,
event.touches[0].pageY,
event.touches[1].pageX,
event.touches[1].pageY
);
}

/**
* Updates the CSS transform zoom based on the distance of the pinch gesture.
*
* @protected
* @param {Event} event - object
* @return {void}
*/
pinchToZoomChangeHandler(event) {
if (!this.isPinching) {
return;
}

const scale = event.scale
? event.scale
: getDistance(
event.touches[0].pageX,
event.touches[0].pageY,
event.touches[1].pageX,
event.touches[1].pageY
) / this.originalDistance;

const proposedNewScale = this.pdfViewer.currentScale * scale;
if (
scale === 1 ||
Math.abs(this.pinchScale - scale) < MIN_PINCH_SCALE_DELTA ||
proposedNewScale >= MAX_SCALE ||
proposedNewScale <= MIN_SCALE ||
scale > MAX_PINCH_SCALE_VALUE ||
scale < MIN_PINCH_SCALE_VALUE
) {
// There are a variety of circumstances where we don't want to scale'
// 1. We haven't detected a changes
// 2. The change isn't significant enough
// 3. We will exceed our max or min scale
// 4. The scale is too significant, which can lead to performance issues
return;
}

this.pinchScale = scale;
this.pinchPage.classList.add(PINCH_PAGE_CLASS);
this.docEl.firstChild.classList.add(PINCHING_CLASS);

this.pinchPage.style.transform = `scale(${this.pinchScale})`;
}

/**
* Replaces the CSS transform with a native PDF.js zoom and scrolls to maintain positioning.
*
* @protected
* @return {void}
*/
pinchToZoomEndHandler() {
if (!this.pinchPage || !this.isPinching || this.pinchScale === 1) {
return;
}

// PDF.js zoom
this.pdfViewer.currentScaleValue = this.pdfViewer.currentScale * this.pinchScale;

this.pinchPage.style.transform = null;
this.pinchPage.style['transform-origin'] = null;
this.pinchPage.classList.remove(PINCH_PAGE_CLASS);
this.docEl.firstChild.classList.remove(PINCHING_CLASS);

// Scroll to correct position after zoom
this.docEl.scroll(
this.scaledXOffset * this.pinchScale - this.originalXOffset,
this.scaledYOffset * this.pinchScale - this.originalYOffset + this.pinchPage.offsetTop
);

this.isPinching = false;
this.originalDistance = 0;
this.pinchScale = 1;
this.pinchPage = null;
}
}

export default DocBaseViewer;

0 comments on commit b59b453

Please sign in to comment.