Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/lib/viewers/doc/DocBaseViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class DocBaseViewer extends BaseViewer {
this.pinchToZoomChangeHandler = this.pinchToZoomChangeHandler.bind(this);
this.pinchToZoomEndHandler = this.pinchToZoomEndHandler.bind(this);
this.pinchToZoomStartHandler = this.pinchToZoomStartHandler.bind(this);
this.wheelZoomHandler = this.wheelZoomHandler.bind(this); // Trackpad pinch-to-zoom support
this.print = this.print.bind(this);
this.setPage = this.setPage.bind(this);
this.throttledScrollHandler = this.getScrollHandler().bind(this);
Expand Down Expand Up @@ -1367,6 +1368,7 @@ class DocBaseViewer extends BaseViewer {
bindDOMListeners() {
this.docEl.addEventListener('keydown', this.handleDocElKeydown);
this.docEl.addEventListener('scroll', this.throttledScrollHandler);
this.docEl.addEventListener('wheel', this.wheelZoomHandler, { passive: false }); // Trackpad pinch-to-zoom

if (this.hasTouch) {
this.docEl.addEventListener('touchstart', this.pinchToZoomStartHandler);
Expand All @@ -1385,6 +1387,7 @@ class DocBaseViewer extends BaseViewer {
if (this.docEl) {
this.docEl.removeEventListener('keydown', this.handleDocElKeydown);
this.docEl.removeEventListener('scroll', this.throttledScrollHandler);
this.docEl.removeEventListener('wheel', this.wheelZoomHandler);

if (this.hasTouch) {
this.docEl.removeEventListener('touchstart', this.pinchToZoomStartHandler);
Expand Down Expand Up @@ -1623,6 +1626,30 @@ class DocBaseViewer extends BaseViewer {
}, SCROLL_EVENT_THROTTLE_INTERVAL);
}

/**
* Handles trackpad pinch-to-zoom via wheel events with ctrlKey.
* On Mac trackpads, pinch gestures fire wheel events with ctrlKey set to true.
*
* @protected
* @param {WheelEvent} event - wheel event object
* @return {void}
*/
wheelZoomHandler(event) {
if (!event.ctrlKey) {
return;
}

event.preventDefault();

const { currentScale } = this.pdfViewer;
const delta = -event.deltaY * MIN_PINCH_SCALE_DELTA;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, currentScale * (1 + delta)));

if (newScale !== currentScale) {
this.updateScale(parseFloat(newScale.toFixed(MAX_PINCH_SCALE_VALUE)));
}
}

/**
* Sets up pinch to zoom behavior by wrapping zoomed divs and determining the original pinch distance.
*
Expand Down
58 changes: 58 additions & 0 deletions src/lib/viewers/doc/__tests__/DocBaseViewer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => {

expect(stubs.addEventListener).toBeCalledWith('keydown', docBase.handleDocElKeydown);
expect(stubs.addEventListener).toBeCalledWith('scroll', docBase.throttledScrollHandler);
expect(stubs.addEventListener).toBeCalledWith('wheel', docBase.wheelZoomHandler, { passive: false });
expect(stubs.addEventListener).not.toBeCalledWith('touchstart', docBase.pinchToZoomStartHandler);
expect(stubs.addEventListener).not.toBeCalledWith('touchmove', docBase.pinchToZoomChangeHandler);
expect(stubs.addEventListener).not.toBeCalledWith('touchend', docBase.pinchToZoomEndHandler);
Expand All @@ -2475,6 +2476,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => {

expect(stubs.removeEventListener).toBeCalledWith('keydown', docBase.handleDocElKeydown);
expect(stubs.removeEventListener).toBeCalledWith('scroll', docBase.throttledScrollHandler);
expect(stubs.removeEventListener).toBeCalledWith('wheel', docBase.wheelZoomHandler);
});

test('should not remove the doc element listeners if the doc element does not exist', () => {
Expand Down Expand Up @@ -2861,6 +2863,62 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => {
});
});

describe('wheelZoomHandler()', () => {
test('should do nothing if ctrlKey is not pressed', () => {
jest.spyOn(docBase, 'updateScale').mockImplementation();
const event = { ctrlKey: false, deltaY: -1, preventDefault: jest.fn() };

docBase.wheelZoomHandler(event);
expect(event.preventDefault).not.toBeCalled();
expect(docBase.updateScale).not.toBeCalled();
});

test('should zoom in smoothly when deltaY is negative', () => {
docBase.pdfViewer = { currentScale: 1.0 };
jest.spyOn(docBase, 'updateScale').mockImplementation();
const event = { ctrlKey: true, deltaY: -10, preventDefault: jest.fn() };

docBase.wheelZoomHandler(event);
expect(event.preventDefault).toBeCalled();
expect(docBase.updateScale).toBeCalledWith(expect.any(Number));
expect(docBase.updateScale.mock.calls[0][0]).toBeGreaterThan(1.0);
});

test('should zoom out smoothly when deltaY is positive', () => {
docBase.pdfViewer = { currentScale: 1.0 };
jest.spyOn(docBase, 'updateScale').mockImplementation();
const event = { ctrlKey: true, deltaY: 10, preventDefault: jest.fn() };

docBase.wheelZoomHandler(event);
expect(event.preventDefault).toBeCalled();
expect(docBase.updateScale).toBeCalledWith(expect.any(Number));
expect(docBase.updateScale.mock.calls[0][0]).toBeLessThan(1.0);
});

test('should not exceed MAX_SCALE', () => {
docBase.pdfViewer = { currentScale: 10.0 };
jest.spyOn(docBase, 'updateScale').mockImplementation();
const event = { ctrlKey: true, deltaY: -100, preventDefault: jest.fn() };

docBase.wheelZoomHandler(event);
// Scale should be capped, so updateScale should not be called with a value above 10
if (docBase.updateScale.mock.calls.length > 0) {
expect(docBase.updateScale.mock.calls[0][0]).toBeLessThanOrEqual(10.0);
}
});

test('should not go below MIN_SCALE', () => {
docBase.pdfViewer = { currentScale: 0.1 };
jest.spyOn(docBase, 'updateScale').mockImplementation();
const event = { ctrlKey: true, deltaY: 100, preventDefault: jest.fn() };

docBase.wheelZoomHandler(event);
if (docBase.updateScale.mock.calls.length > 0) {
expect(docBase.updateScale.mock.calls[0][0]).toBeGreaterThanOrEqual(0.1);
}
});
});

describe('pinchToZoomStartHandler()', () => {
let event;

Expand Down
47 changes: 47 additions & 0 deletions src/lib/viewers/image/ImageBaseViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { openContentInsideIframe } from '../../util';
const CSS_CLASS_PANNING = 'panning';
const CSS_CLASS_ZOOMABLE = 'zoomable';
const CSS_CLASS_PANNABLE = 'pannable';
const MIN_PINCH_SCALE_DELTA = 0.01;

class ImageBaseViewer extends BaseViewer {
/** @inheritdoc */
Expand All @@ -31,6 +32,8 @@ class ImageBaseViewer extends BaseViewer {
this.finishLoading = this.finishLoading.bind(this);
this.isDiscoverabilityEnabled = this.isDiscoverabilityEnabled.bind(this);

// Trackpad pinch-to-zoom support
this.wheelZoomHandler = this.wheelZoomHandler.bind(this);
if (this.isMobile) {
if (Browser.isIOS()) {
this.mobileZoomStartHandler = this.mobileZoomStartHandler.bind(this);
Expand Down Expand Up @@ -272,6 +275,8 @@ class ImageBaseViewer extends BaseViewer {
this.imageEl.addEventListener('mousedown', this.handleMouseDown);
this.imageEl.addEventListener('mouseup', this.handleMouseUp);
this.imageEl.addEventListener('dragstart', this.cancelDragEvent);
// Trackpad pinch-to-zoom
this.imageEl.addEventListener('wheel', this.wheelZoomHandler, { passive: false });

if (this.isMobile) {
if (Browser.isIOS()) {
Expand Down Expand Up @@ -302,6 +307,7 @@ class ImageBaseViewer extends BaseViewer {
this.imageEl.removeEventListener('mousedown', this.handleMouseDown);
this.imageEl.removeEventListener('mouseup', this.handleMouseUp);
this.imageEl.removeEventListener('dragstart', this.cancelDragEvent);
this.imageEl.removeEventListener('wheel', this.wheelZoomHandler);

this.imageEl.removeEventListener('gesturestart', this.mobileZoomStartHandler);
this.imageEl.removeEventListener('gestureend', this.mobileZoomEndHandler);
Expand All @@ -310,6 +316,47 @@ class ImageBaseViewer extends BaseViewer {
this.imageEl.removeEventListener('touchend', this.mobileZoomEndHandler);
}

/**
* Handles trackpad pinch-to-zoom via wheel events with ctrlKey.
* On Mac trackpads, pinch gestures fire wheel events with ctrlKey set to true.
*
* @protected
* @param {WheelEvent} event - wheel event object
* @return {void}
*/
wheelZoomHandler(event) {
if (!event.ctrlKey) {
return;
}

event.preventDefault();

const delta = -event.deltaY * MIN_PINCH_SCALE_DELTA;
const currentWidth = this.imageEl.offsetWidth;
const newWidth = currentWidth * (1 + delta);

this.imageEl.style.width = `${newWidth}px`;
this.imageEl.style.height = '';

if (typeof this.adjustImageZoomPadding === 'function') {
this.adjustImageZoomPadding();
}

if (typeof this.setScale === 'function') {
this.setScale(newWidth);
}

if (typeof this.updatePannability === 'function') {
setTimeout(this.updatePannability, 50);
}

this.emit('zoom', {
newScale: newWidth,
canZoomIn: true,
canZoomOut: true,
});
}

/**
* Handles a content download error
*
Expand Down
80 changes: 77 additions & 3 deletions src/lib/viewers/image/__tests__/ImageBaseViewer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@ describe('lib/viewers/image/ImageBaseViewer', () => {
jest.spyOn(document, 'addEventListener');
stubs.listeners = imageBase.imageEl.addEventListener;
imageBase.isMobile = true;
imageBase.mobileZoomStartHandler = imageBase.mobileZoomStartHandler.bind(imageBase);
imageBase.mobileZoomChangeHandler = imageBase.mobileZoomChangeHandler.bind(imageBase);
imageBase.mobileZoomEndHandler = imageBase.mobileZoomEndHandler.bind(imageBase);
});

test('should bind all default image listeners', () => {
Expand All @@ -457,20 +460,33 @@ describe('lib/viewers/image/ImageBaseViewer', () => {
expect(stubs.listeners).toBeCalledWith('dragstart', imageBase.cancelDragEvent);
});

test('should bind all iOS listeners', () => {
test('should bind wheel listener for trackpad zoom', () => {
imageBase.bindDOMListeners();
expect(stubs.listeners).toBeCalledWith('wheel', imageBase.wheelZoomHandler, { passive: false });
});

test('should bind all iOS listeners when hasTouch is true', () => {
jest.spyOn(Browser, 'isIOS').mockReturnValue(true);
imageBase.bindDOMListeners();
expect(stubs.listeners).toBeCalledWith('gesturestart', imageBase.mobileZoomStartHandler);
expect(stubs.listeners).toBeCalledWith('gestureend', imageBase.mobileZoomEndHandler);
});

test('should bind all mobile and non-iOS listeners', () => {
test('should bind all touch listeners when hasTouch is true and not iOS', () => {
jest.spyOn(Browser, 'isIOS').mockReturnValue(false);
imageBase.bindDOMListeners();
expect(stubs.listeners).toBeCalledWith('touchstart', imageBase.mobileZoomStartHandler);
expect(stubs.listeners).toBeCalledWith('touchmove', imageBase.mobileZoomChangeHandler);
expect(stubs.listeners).toBeCalledWith('touchend', imageBase.mobileZoomEndHandler);
});

test('should not bind touch listeners when hasTouch is false', () => {
imageBase.isMobile = false;
jest.spyOn(Browser, 'isIOS').mockReturnValue(false);
imageBase.bindDOMListeners();
expect(stubs.listeners).not.toBeCalledWith('touchstart', imageBase.mobileZoomStartHandler);
expect(stubs.listeners).not.toBeCalledWith('gesturestart', imageBase.mobileZoomStartHandler);
});
});

describe('unbindDOMListeners()', () => {
Expand All @@ -483,7 +499,7 @@ describe('lib/viewers/image/ImageBaseViewer', () => {
imageBase.imageEl.removeEventListener = jest.fn();
stubs.listeners = imageBase.imageEl.removeEventListener;
stubs.documentListener = jest.spyOn(document, 'removeEventListener');
imageBase.isMobile = true;
imageBase.hasTouch = true;
});

test('should unbind all default image listeners if imageEl does not exist', () => {
Expand All @@ -502,6 +518,11 @@ describe('lib/viewers/image/ImageBaseViewer', () => {
expect(stubs.listeners).toBeCalledWith('gestureend', imageBase.mobileZoomEndHandler);
});

test('should unbind wheel listener', () => {
imageBase.unbindDOMListeners();
expect(stubs.listeners).toBeCalledWith('wheel', imageBase.wheelZoomHandler);
});

test('should unbind all document listeners', () => {
imageBase.unbindDOMListeners();
expect(stubs.documentListener).toBeCalledWith('mousemove', imageBase.pan);
Expand All @@ -517,6 +538,59 @@ describe('lib/viewers/image/ImageBaseViewer', () => {
});
});

describe('wheelZoomHandler()', () => {
beforeEach(() => {
// Set up imageEl with a measurable width
imageBase.imageEl.style.width = '100px';
Object.defineProperty(imageBase.imageEl, 'offsetWidth', { value: 100, configurable: true });
jest.spyOn(imageBase, 'emit').mockImplementation();
});

test('should do nothing if ctrlKey is not pressed', () => {
imageBase.wheelZoomHandler({ ctrlKey: false, deltaY: -5, preventDefault: jest.fn() });
expect(imageBase.emit).not.toBeCalled();
});

test('should preventDefault and apply proportional zoom on pinch in', () => {
const event = { ctrlKey: true, deltaY: -5, preventDefault: jest.fn() };

imageBase.wheelZoomHandler(event);
expect(event.preventDefault).toBeCalled();
// deltaY=-5, delta=0.05, newWidth = 100 * 1.05 = 105
expect(imageBase.imageEl.style.width).toBe('105px');
expect(imageBase.imageEl.style.height).toBe('');
});

test('should apply proportional zoom on pinch out', () => {
const event = { ctrlKey: true, deltaY: 5, preventDefault: jest.fn() };

imageBase.wheelZoomHandler(event);
// deltaY=5, delta=-0.05, newWidth = 100 * 0.95 = 95
expect(imageBase.imageEl.style.width).toBe('95px');
});

test('should call adjustImageZoomPadding if available', () => {
imageBase.adjustImageZoomPadding = jest.fn();
imageBase.wheelZoomHandler({ ctrlKey: true, deltaY: -5, preventDefault: jest.fn() });
expect(imageBase.adjustImageZoomPadding).toBeCalled();
});

test('should call setScale if available', () => {
imageBase.setScale = jest.fn();
imageBase.wheelZoomHandler({ ctrlKey: true, deltaY: -5, preventDefault: jest.fn() });
expect(imageBase.setScale).toBeCalledWith(105);
});

test('should emit zoom event', () => {
imageBase.wheelZoomHandler({ ctrlKey: true, deltaY: -5, preventDefault: jest.fn() });
expect(imageBase.emit).toBeCalledWith('zoom', {
newScale: 105,
canZoomIn: true,
canZoomOut: true,
});
});
});

describe('finishLoading()', () => {
beforeEach(() => {
imageBase.loaded = false;
Expand Down
Loading