Skip to content

Commit f6f4ab4

Browse files
jnieminJussi Nieminen
andauthored
[BUGFIX] Zoom in and zoom out steps are now more predictable (#780)
Co-authored-by: Jussi Nieminen <jnieminen@pdftron.com>
1 parent 1df0321 commit f6f4ab4

File tree

3 files changed

+129
-49
lines changed

3 files changed

+129
-49
lines changed

src/components/ZoomOverlay/ZoomOverlay.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import Icon from 'components/Icon';
2-
import core from 'core';
3-
import { zoomTo } from 'helpers/zoom';
2+
import { zoomTo, fitToPage, fitToWidth } from 'helpers/zoom';
43
import React from 'react';
54
import { useTranslation } from 'react-i18next';
65
import { useSelector } from 'react-redux';
@@ -17,11 +16,11 @@ function ZoomOverlay() {
1716

1817
return (
1918
<FlyoutMenu menu="zoomOverlay" trigger="zoomOverlayButton">
20-
<button className="ZoomItem" onClick={core.fitToWidth} aria-label={t('action.fitToWidth')}>
19+
<button className="ZoomItem" onClick={fitToWidth} aria-label={t('action.fitToWidth')}>
2120
<Icon className="ZoomIcon" glyph="icon-header-zoom-fit-to-width" />
2221
<div className="ZoomLabel">{t('action.fitToWidth')}</div>
2322
</button>
24-
<button className="ZoomItem" onClick={core.fitToPage} aria-label={t('action.fitToPage')}>
23+
<button className="ZoomItem" onClick={fitToPage} aria-label={t('action.fitToPage')}>
2524
<Icon className="ZoomIcon" glyph="icon-header-zoom-fit-to-page" />
2625
<div className="ZoomLabel">{t('action.fitToPage')}</div>
2726
</button>

src/constants/zoomFactors.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ export const setMaxZoomLevel = zoom => {
1616
export const stepToZoomFactorRangesMap = {
1717
'0.075': [null, 0.8],
1818
'0.25': [0.8, 1.5],
19-
'1': [1.5, 3.5],
20-
'2': [3.5, 8],
19+
'0.5': [1.5, 2.5],
20+
'1': [2.5, 4],
21+
'2': [4, 8],
2122
'4': [8, 32],
2223
'8': [32, 64],
2324
'16': [64, null],

src/helpers/zoom.js

Lines changed: 123 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,145 @@
11
import core from 'core';
22
import { stepToZoomFactorRangesMap, getMaxZoomLevel, getMinZoomLevel } from 'constants/zoomFactors';
33

4-
export const zoomIn = () => {
5-
const currentZoomFactor = core.getZoom();
6-
if (currentZoomFactor === getMaxZoomLevel()) {
7-
return;
8-
}
9-
10-
const step = getStep(currentZoomFactor);
11-
const newZoomFactor = currentZoomFactor + step;
12-
zoomTo(Math.min(newZoomFactor, getMaxZoomLevel()));
13-
};
14-
15-
export const zoomOut = () => {
16-
const currentZoomFactor = core.getZoom();
17-
if (currentZoomFactor === getMinZoomLevel()) {
18-
return;
19-
}
20-
21-
const step = getStep(currentZoomFactor);
22-
const newZoomFactor = currentZoomFactor - step;
23-
zoomTo(Math.max(newZoomFactor, getMinZoomLevel()));
24-
};
25-
26-
const getStep = currentZoomFactor => {
4+
function getStep(currentZoomFactor) {
275
const steps = Object.keys(stepToZoomFactorRangesMap);
286
const step = steps.find(step => {
297
const zoomFactorRanges = stepToZoomFactorRangesMap[step];
30-
318
return isCurrentZoomFactorInRange(currentZoomFactor, zoomFactorRanges);
329
});
3310

3411
return parseFloat(step);
35-
};
12+
}
3613

37-
const isCurrentZoomFactorInRange = (zoomFactor, ranges) => {
38-
if (ranges[0] === null) {
39-
return zoomFactor <= ranges[1];
14+
function isCurrentZoomFactorInRange(zoomFactor, ranges) {
15+
const [rangeLowBound, rangeHighBound] = ranges;
16+
if (rangeLowBound === null) {
17+
return zoomFactor < rangeHighBound;
4018
}
41-
if (ranges[1] === null) {
42-
return zoomFactor >= ranges[0];
19+
if (rangeHighBound === null) {
20+
return zoomFactor >= rangeLowBound;
4321
}
44-
return zoomFactor >= ranges[0] && zoomFactor <= ranges[1];
45-
};
46-
47-
export const zoomTo = newZoomFactor => {
48-
const currentZoomFactor = core.getZoom();
49-
const scale = newZoomFactor / currentZoomFactor;
50-
const { x, y } = getViewCenterAfterScale(scale);
51-
52-
core.zoomTo(newZoomFactor, x, y);
53-
};
22+
return zoomFactor >= rangeLowBound && zoomFactor < rangeHighBound;
23+
}
5424

55-
const getViewCenterAfterScale = scale => {
25+
function getViewCenterAfterScale(scale) {
5626
const documentContainer = document.getElementsByClassName('DocumentContainer')[0];
5727
const documentWrapper = document.getElementsByClassName('document')[0];
5828
const clientX = window.innerWidth / 2;
5929
const clientY = window.innerHeight / 2;
6030

6131
const x = (clientX + documentContainer.scrollLeft - documentWrapper.offsetLeft) * scale - clientX + documentContainer.offsetLeft;
6232
const y = (clientY + documentContainer.scrollTop - documentWrapper.offsetTop) * scale - clientY + documentContainer.offsetTop;
63-
6433
return { x, y };
65-
};
34+
}
35+
36+
let zoomStepHistory = [];
37+
38+
// Keeping track of changes to zoomFactor outside this helper functions
39+
let storedZoomFactor = -1;
40+
41+
function zoomToInternal(currentZoomFactor, newZoomFactor) {
42+
const scale = newZoomFactor / currentZoomFactor;
43+
const { x, y } = getViewCenterAfterScale(scale);
44+
core.zoomTo(newZoomFactor, x, y);
45+
storedZoomFactor = newZoomFactor;
46+
}
47+
48+
function resetZoomStepHistory() {
49+
zoomStepHistory = [];
50+
}
51+
52+
export function fitToWidth() {
53+
resetZoomStepHistory();
54+
core.fitToWidth();
55+
}
56+
57+
export function fitToPage() {
58+
resetZoomStepHistory();
59+
core.fitToPage();
60+
}
61+
62+
/**
63+
* zoomIn works as follows. Every time user zooms in we take current zoom level and compare it to
64+
* zoom factors map which currently is :
65+
* {
66+
'0.075': [null, 0.8],
67+
'0.25': [0.8, 1.5],
68+
'0.5': [1.5, 2.5],
69+
'1': [2.5, 4],
70+
'2': [4, 8],
71+
'4': [8, 32],
72+
'8': [32, 64],
73+
'16': [64, null],
74+
}
75+
* Based on the range we select step size from this map. For example if current zoom level is 110% (1.1)
76+
* then we can see that it falls to range [0.8, 1.5] and step size for this is 0.25. We add this step
77+
* to current level so we end up to 1.35. We also store this step to stack so we can follow how after few steps we got there.
78+
*
79+
* We need to keep the step history to make our steps predictable in all cases.
80+
* Consider case where we start from zoom level 140% (1.4) and zoomIn. We would end up to 1.4 + 0.25 = 1.65 (165%).
81+
* If user would now click zoomOut, we would end up 1.65 - 0.5 = 1.15 (115%) which is not the same 140% where we started.
82+
* But as we store the step history we do 1.65 - 0.25 (value from step history) and end up to 1.4 (140%).
83+
*/
84+
export function zoomIn() {
85+
const currentZoomFactor = core.getZoom();
86+
if (storedZoomFactor > 0 && currentZoomFactor !== storedZoomFactor) {
87+
// zoom level was changed by external side effect (like one of core's function to change zoom level)
88+
// in these cases we need to reset step history
89+
resetZoomStepHistory();
90+
}
91+
if (currentZoomFactor === getMaxZoomLevel()) {
92+
return;
93+
}
94+
95+
let step = getStep(currentZoomFactor);
96+
if (zoomStepHistory.length > 0 && zoomStepHistory[zoomStepHistory.length - 1] < 0) {
97+
// if step history has steps and it has been opposite direction (zoomOut)
98+
// We use that step. This makes sure that when crossing step range, zoom level goes to same
99+
// as it was when zoomOut was done.
100+
// We differentiate zoomIn and zoomOut steps by zoomOut steps are negative and zoomIn are positive
101+
// thus here using absolute value
102+
step = Math.abs(zoomStepHistory.pop());
103+
} else {
104+
// We differentiate zoomIn and zoomOut steps by zoomOut steps are negative and zoomIn are positive
105+
zoomStepHistory.push(step);
106+
}
107+
const newZoomFactor = Math.min(currentZoomFactor + step, getMaxZoomLevel());
108+
zoomToInternal(currentZoomFactor, newZoomFactor);
109+
}
110+
111+
/**
112+
* See functionality from zoomIn. zoomOut works same but opposite direction.
113+
*/
114+
export function zoomOut() {
115+
const currentZoomFactor = core.getZoom();
116+
if (storedZoomFactor > 0 && currentZoomFactor !== storedZoomFactor) {
117+
// zoom level was changed by external side effect (like one of core's function to change zoom level)
118+
// in these cases we need to reset step history
119+
resetZoomStepHistory();
120+
}
121+
if (currentZoomFactor === getMinZoomLevel()) {
122+
return;
123+
}
124+
125+
let step = getStep(currentZoomFactor);
126+
if (zoomStepHistory.length > 0 && zoomStepHistory[zoomStepHistory.length -1] > 0) {
127+
// if step history has steps and it has been opposite direction (zoomIn)
128+
// We use that step. This makes sure that when crossing step range, zoom level goes to same
129+
// as it was when zoomIn was done.
130+
// We differentiate zoomIn and zoomOut steps by zoomOut steps are negative and zoomIn are positive
131+
step = zoomStepHistory.pop();
132+
} else {
133+
// We differentiate zoomIn and zoomOut steps by zoomOut steps are negative and zoomIn are positive
134+
zoomStepHistory.push(-1 * step);
135+
}
136+
const newZoomFactor = Math.max(currentZoomFactor - step, getMinZoomLevel());
137+
zoomToInternal(currentZoomFactor, newZoomFactor);
138+
}
139+
140+
export function zoomTo(newZoomFactor) {
141+
// if user sets certain zoom level, then we reset the step history
142+
resetZoomStepHistory();
143+
const currentZoomFactor = core.getZoom();
144+
zoomToInternal(currentZoomFactor, newZoomFactor);
145+
}

0 commit comments

Comments
 (0)